experimaestro 2.0.0b8__py3-none-any.whl → 2.0.0b17__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.

Potentially problematic release.


This version of experimaestro might be problematic. Click here for more details.

Files changed (152) hide show
  1. experimaestro/__init__.py +12 -5
  2. experimaestro/cli/__init__.py +239 -126
  3. experimaestro/cli/filter.py +48 -23
  4. experimaestro/cli/jobs.py +253 -71
  5. experimaestro/cli/refactor.py +1 -2
  6. experimaestro/commandline.py +7 -4
  7. experimaestro/connectors/__init__.py +9 -1
  8. experimaestro/connectors/local.py +43 -3
  9. experimaestro/core/arguments.py +18 -18
  10. experimaestro/core/identifier.py +11 -11
  11. experimaestro/core/objects/config.py +96 -39
  12. experimaestro/core/objects/config_walk.py +3 -3
  13. experimaestro/core/{subparameters.py → partial.py} +16 -16
  14. experimaestro/core/partial_lock.py +394 -0
  15. experimaestro/core/types.py +12 -15
  16. experimaestro/dynamic.py +290 -0
  17. experimaestro/experiments/__init__.py +6 -2
  18. experimaestro/experiments/cli.py +217 -50
  19. experimaestro/experiments/configuration.py +24 -0
  20. experimaestro/generators.py +5 -5
  21. experimaestro/ipc.py +118 -1
  22. experimaestro/launcherfinder/__init__.py +2 -2
  23. experimaestro/launcherfinder/registry.py +6 -7
  24. experimaestro/launcherfinder/specs.py +2 -9
  25. experimaestro/launchers/slurm/__init__.py +2 -2
  26. experimaestro/launchers/slurm/base.py +62 -0
  27. experimaestro/locking.py +957 -1
  28. experimaestro/notifications.py +89 -201
  29. experimaestro/progress.py +63 -366
  30. experimaestro/rpyc.py +0 -2
  31. experimaestro/run.py +29 -2
  32. experimaestro/scheduler/__init__.py +8 -1
  33. experimaestro/scheduler/base.py +629 -53
  34. experimaestro/scheduler/dependencies.py +20 -16
  35. experimaestro/scheduler/experiment.py +732 -167
  36. experimaestro/scheduler/interfaces.py +316 -101
  37. experimaestro/scheduler/jobs.py +58 -20
  38. experimaestro/scheduler/remote/adaptive_sync.py +265 -0
  39. experimaestro/scheduler/remote/client.py +171 -117
  40. experimaestro/scheduler/remote/protocol.py +8 -193
  41. experimaestro/scheduler/remote/server.py +95 -71
  42. experimaestro/scheduler/services.py +53 -28
  43. experimaestro/scheduler/state_provider.py +663 -2430
  44. experimaestro/scheduler/state_status.py +1247 -0
  45. experimaestro/scheduler/transient.py +31 -0
  46. experimaestro/scheduler/workspace.py +1 -1
  47. experimaestro/scheduler/workspace_state_provider.py +1273 -0
  48. experimaestro/scriptbuilder.py +4 -4
  49. experimaestro/settings.py +36 -0
  50. experimaestro/tests/conftest.py +33 -5
  51. experimaestro/tests/connectors/bin/executable.py +1 -1
  52. experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
  53. experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
  54. experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
  55. experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
  56. experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
  57. experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
  58. experimaestro/tests/launchers/bin/test.py +1 -0
  59. experimaestro/tests/launchers/test_slurm.py +9 -9
  60. experimaestro/tests/partial_reschedule.py +46 -0
  61. experimaestro/tests/restart.py +3 -3
  62. experimaestro/tests/restart_main.py +1 -0
  63. experimaestro/tests/scripts/notifyandwait.py +1 -0
  64. experimaestro/tests/task_partial.py +38 -0
  65. experimaestro/tests/task_tokens.py +2 -2
  66. experimaestro/tests/tasks/test_dynamic.py +6 -6
  67. experimaestro/tests/test_dependencies.py +3 -3
  68. experimaestro/tests/test_deprecated.py +15 -15
  69. experimaestro/tests/test_dynamic_locking.py +317 -0
  70. experimaestro/tests/test_environment.py +24 -14
  71. experimaestro/tests/test_experiment.py +171 -36
  72. experimaestro/tests/test_identifier.py +25 -25
  73. experimaestro/tests/test_identifier_stability.py +3 -5
  74. experimaestro/tests/test_multitoken.py +2 -4
  75. experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
  76. experimaestro/tests/test_partial_paths.py +81 -138
  77. experimaestro/tests/test_pre_experiment.py +219 -0
  78. experimaestro/tests/test_progress.py +2 -8
  79. experimaestro/tests/test_remote_state.py +560 -99
  80. experimaestro/tests/test_stray_jobs.py +261 -0
  81. experimaestro/tests/test_tasks.py +1 -2
  82. experimaestro/tests/test_token_locking.py +52 -67
  83. experimaestro/tests/test_tokens.py +5 -6
  84. experimaestro/tests/test_transient.py +225 -0
  85. experimaestro/tests/test_workspace_state_provider.py +768 -0
  86. experimaestro/tests/token_reschedule.py +1 -3
  87. experimaestro/tests/utils.py +2 -7
  88. experimaestro/tokens.py +227 -372
  89. experimaestro/tools/diff.py +1 -0
  90. experimaestro/tools/documentation.py +4 -5
  91. experimaestro/tools/jobs.py +1 -2
  92. experimaestro/tui/app.py +438 -1966
  93. experimaestro/tui/app.tcss +162 -0
  94. experimaestro/tui/dialogs.py +172 -0
  95. experimaestro/tui/log_viewer.py +253 -3
  96. experimaestro/tui/messages.py +137 -0
  97. experimaestro/tui/utils.py +54 -0
  98. experimaestro/tui/widgets/__init__.py +23 -0
  99. experimaestro/tui/widgets/experiments.py +468 -0
  100. experimaestro/tui/widgets/global_services.py +238 -0
  101. experimaestro/tui/widgets/jobs.py +972 -0
  102. experimaestro/tui/widgets/log.py +156 -0
  103. experimaestro/tui/widgets/orphans.py +363 -0
  104. experimaestro/tui/widgets/runs.py +185 -0
  105. experimaestro/tui/widgets/services.py +314 -0
  106. experimaestro/tui/widgets/stray_jobs.py +528 -0
  107. experimaestro/utils/__init__.py +1 -1
  108. experimaestro/utils/environment.py +105 -22
  109. experimaestro/utils/fswatcher.py +124 -0
  110. experimaestro/utils/jobs.py +1 -2
  111. experimaestro/utils/jupyter.py +1 -2
  112. experimaestro/utils/logging.py +72 -0
  113. experimaestro/version.py +2 -2
  114. experimaestro/webui/__init__.py +9 -0
  115. experimaestro/webui/app.py +117 -0
  116. experimaestro/{server → webui}/data/index.css +66 -11
  117. experimaestro/webui/data/index.css.map +1 -0
  118. experimaestro/{server → webui}/data/index.js +82763 -87217
  119. experimaestro/webui/data/index.js.map +1 -0
  120. experimaestro/webui/routes/__init__.py +5 -0
  121. experimaestro/webui/routes/auth.py +53 -0
  122. experimaestro/webui/routes/proxy.py +117 -0
  123. experimaestro/webui/server.py +200 -0
  124. experimaestro/webui/state_bridge.py +152 -0
  125. experimaestro/webui/websocket.py +413 -0
  126. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
  127. experimaestro-2.0.0b17.dist-info/RECORD +219 -0
  128. experimaestro/cli/progress.py +0 -269
  129. experimaestro/scheduler/state.py +0 -75
  130. experimaestro/scheduler/state_db.py +0 -437
  131. experimaestro/scheduler/state_sync.py +0 -891
  132. experimaestro/server/__init__.py +0 -467
  133. experimaestro/server/data/index.css.map +0 -1
  134. experimaestro/server/data/index.js.map +0 -1
  135. experimaestro/tests/test_cli_jobs.py +0 -615
  136. experimaestro/tests/test_file_progress.py +0 -425
  137. experimaestro/tests/test_file_progress_integration.py +0 -477
  138. experimaestro/tests/test_state_db.py +0 -434
  139. experimaestro-2.0.0b8.dist-info/RECORD +0 -187
  140. /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
  141. /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
  142. /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
  143. /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
  144. /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
  145. /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
  146. /experimaestro/{server → webui}/data/favicon.ico +0 -0
  147. /experimaestro/{server → webui}/data/index.html +0 -0
  148. /experimaestro/{server → webui}/data/login.html +0 -0
  149. /experimaestro/{server → webui}/data/manifest.json +0 -0
  150. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
  151. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
  152. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,768 @@
1
+ """Tests for WorkspaceStateProvider
2
+
3
+ Tests cover:
4
+ 1. Detection of v1 and v2 experiment layouts
5
+ 2. Event detection when events-*.jsonl files are updated
6
+ 3. Reading jobs, tags, dependencies from status.json
7
+ 4. Getting experiment runs (multiple runs per experiment)
8
+ """
9
+
10
+ import json
11
+ import pytest
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from experimaestro.scheduler.workspace_state_provider import WorkspaceStateProvider
17
+ from experimaestro.scheduler.state_status import (
18
+ JobStateChangedEvent,
19
+ JobProgressEvent,
20
+ ExperimentUpdatedEvent,
21
+ )
22
+
23
+
24
+ # =============================================================================
25
+ # Mock Workspace Helpers - Reusable for other tests
26
+ # =============================================================================
27
+
28
+
29
+ def create_v1_experiment(
30
+ workspace: Path,
31
+ experiment_id: str,
32
+ jobs: list[
33
+ tuple[str, str, str]
34
+ ], # (task_id, job_id, status: "done"|"error"|"running")
35
+ ) -> Path:
36
+ """Create a v1 layout experiment.
37
+
38
+ v1 layout: xp/{exp-id}/jobs/{task_id}/{job_id} -> symlink to jobs/{task_id}/{job_id}
39
+ """
40
+ exp_dir = workspace / "xp" / experiment_id
41
+ exp_dir.mkdir(parents=True, exist_ok=True)
42
+
43
+ jobs_dir = exp_dir / "jobs"
44
+ jobs_dir.mkdir(exist_ok=True)
45
+
46
+ # Create actual job directories in workspace/jobs/
47
+ actual_jobs_dir = workspace / "jobs"
48
+
49
+ for task_id, job_id, status in jobs:
50
+ # Create actual job directory
51
+ job_path = actual_jobs_dir / task_id / job_id
52
+ job_path.mkdir(parents=True, exist_ok=True)
53
+
54
+ # Create status file based on status
55
+ scriptname = task_id.rsplit(".", 1)[-1]
56
+ if status == "done":
57
+ (job_path / f"{scriptname}.done").touch()
58
+ elif status == "error":
59
+ (job_path / f"{scriptname}.failed").touch()
60
+ # "running" has no status file
61
+
62
+ # Create symlink in experiment jobs/
63
+ task_jobs_dir = jobs_dir / task_id
64
+ task_jobs_dir.mkdir(exist_ok=True)
65
+ link_path = task_jobs_dir / job_id
66
+ link_path.symlink_to(job_path)
67
+
68
+ return exp_dir
69
+
70
+
71
+ def create_v2_experiment(
72
+ workspace: Path,
73
+ experiment_id: str,
74
+ runs: list[tuple[str, str, list[tuple[str, str, str]]]], # (run_id, status, jobs)
75
+ current_run: Optional[str] = None,
76
+ ) -> Path:
77
+ """Create a v2 layout experiment with multiple runs.
78
+
79
+ v2 layout:
80
+ experiments/{exp-id}/{run-id}/status.json
81
+ .events/experiments/{exp-id} -> ../../experiments/{exp-id}/{current_run}
82
+ """
83
+ exp_dir = workspace / "experiments" / experiment_id
84
+ exp_dir.mkdir(parents=True, exist_ok=True)
85
+
86
+ for run_id, run_status, jobs in runs:
87
+ run_dir = exp_dir / run_id
88
+ run_dir.mkdir(exist_ok=True)
89
+
90
+ # Create jobs.jsonl with job info and count finished/failed
91
+ jobs_jsonl_lines = []
92
+ finished_count = 0
93
+ failed_count = 0
94
+ for task_id, job_id, job_status in jobs:
95
+ job_info = {
96
+ "job_id": job_id,
97
+ "task_id": task_id,
98
+ "tags": {"task": task_id.split(".")[-1]},
99
+ "timestamp": 1704103200.0, # 2024-01-01T10:00:00
100
+ }
101
+ jobs_jsonl_lines.append(json.dumps(job_info))
102
+
103
+ # Count finished and failed jobs
104
+ if job_status == "done":
105
+ finished_count += 1
106
+ elif job_status == "error":
107
+ failed_count += 1
108
+
109
+ # Create job directory with status marker
110
+ job_dir = workspace / "jobs" / task_id / job_id
111
+ job_dir.mkdir(parents=True, exist_ok=True)
112
+ scriptname = task_id.rsplit(".", 1)[-1]
113
+ if job_status == "done":
114
+ (job_dir / f"{scriptname}.done").touch()
115
+ elif job_status == "error":
116
+ (job_dir / f"{scriptname}.failed").touch()
117
+ # "running" and "waiting" have no marker files
118
+
119
+ (run_dir / "jobs.jsonl").write_text("\n".join(jobs_jsonl_lines))
120
+
121
+ status_data = {
122
+ "version": 1,
123
+ "experiment_id": experiment_id,
124
+ "run_id": run_id,
125
+ "events_count": 0,
126
+ "hostname": "test-host",
127
+ "started_at": "2026-01-01T10:00:00",
128
+ "ended_at": "2026-01-01T11:00:00" if run_status != "active" else None,
129
+ "status": run_status,
130
+ "finished_jobs": finished_count,
131
+ "failed_jobs": failed_count,
132
+ "dependencies": {},
133
+ "services": {},
134
+ }
135
+ (run_dir / "status.json").write_text(json.dumps(status_data))
136
+
137
+ # Also create environment.json for compatibility
138
+ env_data = {"run": {"status": run_status}}
139
+ (run_dir / "environment.json").write_text(json.dumps(env_data))
140
+
141
+ # Create symlink for current run
142
+ if current_run:
143
+ symlinks_dir = workspace / ".events" / "experiments"
144
+ symlinks_dir.mkdir(parents=True, exist_ok=True)
145
+ symlink = symlinks_dir / experiment_id
146
+ if symlink.exists():
147
+ symlink.unlink()
148
+ target = Path("../..") / "experiments" / experiment_id / current_run
149
+ symlink.symlink_to(target)
150
+
151
+ return exp_dir
152
+
153
+
154
+ @pytest.fixture
155
+ def mock_workspace(tmp_path):
156
+ """Create a comprehensive mock workspace with v1 and v2 experiments.
157
+
158
+ Structure:
159
+ workspace/
160
+ .events/experiments/
161
+ v2-multi-run -> ../../experiments/v2-multi-run/20260101_120000
162
+ v2-failed -> ../../experiments/v2-failed/20260101_110000
163
+ xp/ # v1 layout
164
+ v1-mixed/
165
+ jobs/
166
+ pkg.TaskA/job-a1 -> ... # done
167
+ pkg.TaskB/job-b1 -> ... # error
168
+ experiments/ # v2 layout
169
+ v2-multi-run/
170
+ 20260101_100000/ # older run, completed
171
+ 20260101_120000/ # current run, active
172
+ v2-failed/
173
+ 20260101_110000/ # failed run
174
+ """
175
+ workspace = tmp_path / "workspace"
176
+ workspace.mkdir()
177
+
178
+ # v1 experiment with mixed job statuses
179
+ create_v1_experiment(
180
+ workspace,
181
+ "v1-mixed",
182
+ jobs=[
183
+ ("pkg.TaskA", "job-a1", "done"),
184
+ ("pkg.TaskA", "job-a2", "done"),
185
+ ("pkg.TaskB", "job-b1", "error"),
186
+ ],
187
+ )
188
+
189
+ # v2 experiment with multiple runs
190
+ create_v2_experiment(
191
+ workspace,
192
+ "v2-multi-run",
193
+ runs=[
194
+ (
195
+ "20260101_100000",
196
+ "completed",
197
+ [
198
+ ("pkg.OldTask", "old-job-1", "done"),
199
+ ],
200
+ ),
201
+ (
202
+ "20260101_120000",
203
+ "active",
204
+ [
205
+ ("pkg.NewTask", "new-job-1", "running"),
206
+ ("pkg.NewTask", "new-job-2", "waiting"),
207
+ ("pkg.NewTask", "new-job-3", "done"),
208
+ ],
209
+ ),
210
+ ],
211
+ current_run="20260101_120000",
212
+ )
213
+
214
+ # v2 experiment that failed
215
+ create_v2_experiment(
216
+ workspace,
217
+ "v2-failed",
218
+ runs=[
219
+ (
220
+ "20260101_110000",
221
+ "failed",
222
+ [
223
+ ("pkg.FailTask", "fail-job-1", "error"),
224
+ ],
225
+ ),
226
+ ],
227
+ current_run="20260101_110000",
228
+ )
229
+
230
+ return workspace
231
+
232
+
233
+ # =============================================================================
234
+ # Tests: Experiment Detection
235
+ # =============================================================================
236
+
237
+
238
+ class TestGetExperiments:
239
+ """Tests for get_experiments() method"""
240
+
241
+ def test_detects_v1_and_v2_experiments(self, mock_workspace):
242
+ """Both v1 and v2 layout experiments should be detected"""
243
+ provider = WorkspaceStateProvider(mock_workspace)
244
+ experiments = provider.get_experiments()
245
+
246
+ exp_ids = {e.experiment_id for e in experiments}
247
+ assert "v1-mixed" in exp_ids, "v1 experiment not detected"
248
+ assert "v2-multi-run" in exp_ids, "v2 experiment not detected"
249
+ assert "v2-failed" in exp_ids, "v2 failed experiment not detected"
250
+
251
+ def test_experiment_stats_v1(self, mock_workspace):
252
+ """v1 experiment should report correct job counts"""
253
+ provider = WorkspaceStateProvider(mock_workspace)
254
+ exp = provider.get_experiment("v1-mixed")
255
+
256
+ assert exp is not None
257
+ assert exp.total_jobs == 3
258
+ assert exp.finished_jobs == 2 # 2 done
259
+ assert exp.failed_jobs == 1 # 1 error
260
+
261
+ def test_experiment_stats_v2(self, mock_workspace):
262
+ """v2 experiment should report correct job counts from current run"""
263
+ provider = WorkspaceStateProvider(mock_workspace)
264
+ exp = provider.get_experiment("v2-multi-run")
265
+
266
+ assert exp is not None
267
+ assert exp.run_id == "20260101_120000"
268
+ assert exp.total_jobs == 3
269
+ assert exp.finished_jobs == 1 # 1 done
270
+ assert exp.failed_jobs == 0
271
+
272
+ def test_failed_experiment_stats(self, mock_workspace):
273
+ """Failed experiment should report error jobs"""
274
+ provider = WorkspaceStateProvider(mock_workspace)
275
+ exp = provider.get_experiment("v2-failed")
276
+
277
+ assert exp is not None
278
+ assert exp.total_jobs == 1
279
+ assert exp.failed_jobs == 1
280
+
281
+
282
+ # =============================================================================
283
+ # Tests: Experiment Runs
284
+ # =============================================================================
285
+
286
+
287
+ class TestGetExperimentRuns:
288
+ """Tests for get_experiment_runs() method"""
289
+
290
+ def test_v2_multiple_runs(self, mock_workspace):
291
+ """v2 experiment should return all runs"""
292
+ provider = WorkspaceStateProvider(mock_workspace)
293
+ runs = provider.get_experiment_runs("v2-multi-run")
294
+
295
+ assert len(runs) == 2
296
+ run_ids = {r.run_id for r in runs}
297
+ assert "20260101_100000" in run_ids
298
+ assert "20260101_120000" in run_ids
299
+
300
+ def test_v2_run_metadata(self, mock_workspace):
301
+ """Run should contain correct metadata"""
302
+ from experimaestro.scheduler.interfaces import ExperimentStatus
303
+
304
+ provider = WorkspaceStateProvider(mock_workspace)
305
+ runs = provider.get_experiment_runs("v2-multi-run")
306
+
307
+ current_run = next(r for r in runs if r.run_id == "20260101_120000")
308
+ assert current_run.status == ExperimentStatus.RUNNING
309
+ assert current_run.hostname == "test-host"
310
+ assert current_run.total_jobs == 3
311
+
312
+ def test_v1_synthetic_run(self, mock_workspace):
313
+ """v1 experiment should return synthetic 'v1' run"""
314
+ provider = WorkspaceStateProvider(mock_workspace)
315
+ runs = provider.get_experiment_runs("v1-mixed")
316
+
317
+ assert len(runs) == 1
318
+ assert runs[0].run_id == "v1"
319
+ assert runs[0].total_jobs == 3
320
+
321
+
322
+ # =============================================================================
323
+ # Tests: Jobs
324
+ # =============================================================================
325
+
326
+
327
+ class TestGetJobs:
328
+ """Tests for get_jobs() method"""
329
+
330
+ def test_get_jobs_v2_current_run(self, mock_workspace):
331
+ """Should return jobs from current run"""
332
+ provider = WorkspaceStateProvider(mock_workspace)
333
+ jobs = provider.get_jobs("v2-multi-run")
334
+
335
+ job_ids = {j.identifier for j in jobs}
336
+ assert "new-job-1" in job_ids
337
+ assert "new-job-2" in job_ids
338
+ assert "new-job-3" in job_ids
339
+ assert "old-job-1" not in job_ids # From older run
340
+
341
+ def test_get_jobs_specific_run(self, mock_workspace):
342
+ """Should return jobs from specified run"""
343
+ provider = WorkspaceStateProvider(mock_workspace)
344
+ jobs = provider.get_jobs("v2-multi-run", run_id="20260101_100000")
345
+
346
+ job_ids = {j.identifier for j in jobs}
347
+ assert "old-job-1" in job_ids
348
+ assert "new-job-1" not in job_ids
349
+
350
+ def test_get_jobs_v1(self, mock_workspace):
351
+ """Should return jobs from v1 experiment"""
352
+ provider = WorkspaceStateProvider(mock_workspace)
353
+ jobs = provider.get_jobs("v1-mixed")
354
+
355
+ job_ids = {j.identifier for j in jobs}
356
+ assert "job-a1" in job_ids
357
+ assert "job-b1" in job_ids
358
+
359
+
360
+ # =============================================================================
361
+ # Tests: Event Detection (File Watcher)
362
+ # =============================================================================
363
+
364
+
365
+ class TestEventWatcher:
366
+ """Tests for EventFileWatcher - event detection when files change"""
367
+
368
+ def test_detects_new_job_event(self, mock_workspace):
369
+ """Should detect new events written to events-*.jsonl"""
370
+ provider = WorkspaceStateProvider(mock_workspace)
371
+
372
+ events_received = []
373
+
374
+ def listener(event):
375
+ events_received.append(event)
376
+
377
+ provider.add_listener(listener)
378
+
379
+ try:
380
+ # Create events file in new subdirectory format
381
+ exp_dir = mock_workspace / ".events" / "experiments" / "v2-multi-run"
382
+ exp_dir.mkdir(parents=True, exist_ok=True)
383
+ events_file = exp_dir / "events-1.jsonl"
384
+
385
+ # Write a job state change event
386
+ event_data = {
387
+ "event_type": "JobStateChangedEvent",
388
+ "job_id": "new-job-1",
389
+ "state": "done",
390
+ "timestamp": time.time(),
391
+ }
392
+ with open(events_file, "w") as f:
393
+ f.write(json.dumps(event_data) + "\n")
394
+
395
+ # Wait for watcher to pick up
396
+ time.sleep(1.0)
397
+
398
+ # Check that event was detected
399
+ job_events = [
400
+ e for e in events_received if isinstance(e, JobStateChangedEvent)
401
+ ]
402
+ assert len(job_events) >= 1, f"Expected job event, got: {events_received}"
403
+ assert any(e.job_id == "new-job-1" for e in job_events)
404
+ finally:
405
+ provider.close()
406
+
407
+ def test_detects_multiple_events(self, mock_workspace):
408
+ """Should detect multiple events appended to file"""
409
+ provider = WorkspaceStateProvider(mock_workspace)
410
+
411
+ events_received = []
412
+ provider.add_listener(lambda e: events_received.append(e))
413
+
414
+ try:
415
+ # Create events file in new subdirectory format
416
+ exp_dir = mock_workspace / ".events" / "experiments" / "v2-multi-run"
417
+ exp_dir.mkdir(parents=True, exist_ok=True)
418
+ events_file = exp_dir / "events-1.jsonl"
419
+
420
+ # Write first event
421
+ with open(events_file, "w") as f:
422
+ f.write(
423
+ json.dumps(
424
+ {
425
+ "event_type": "JobStateChangedEvent",
426
+ "job_id": "job-1",
427
+ "state": "running",
428
+ }
429
+ )
430
+ + "\n"
431
+ )
432
+
433
+ time.sleep(0.7)
434
+
435
+ # Append second event
436
+ with open(events_file, "a") as f:
437
+ f.write(
438
+ json.dumps(
439
+ {
440
+ "event_type": "JobStateChangedEvent",
441
+ "job_id": "job-2",
442
+ "state": "done",
443
+ }
444
+ )
445
+ + "\n"
446
+ )
447
+
448
+ time.sleep(0.7)
449
+
450
+ job_events = [
451
+ e for e in events_received if isinstance(e, JobStateChangedEvent)
452
+ ]
453
+ job_ids = {e.job_id for e in job_events}
454
+ assert "job-1" in job_ids
455
+ assert "job-2" in job_ids
456
+ finally:
457
+ provider.close()
458
+
459
+ def test_detects_job_state_from_job_events(self, mock_workspace):
460
+ """Should detect job state changes from job event files"""
461
+ provider = WorkspaceStateProvider(mock_workspace)
462
+
463
+ events_received = []
464
+ provider.add_listener(lambda e: events_received.append(e))
465
+
466
+ try:
467
+ # Create job events file in .events/jobs/{task_id}/event-{job_id}-{count}.jsonl
468
+ task_id = "my.test.task"
469
+ job_id = "test-job-123"
470
+ task_dir = mock_workspace / ".events" / "jobs" / task_id
471
+ task_dir.mkdir(parents=True, exist_ok=True)
472
+ events_file = task_dir / f"event-{job_id}-0.jsonl"
473
+
474
+ # Write job state changed event (from job process)
475
+ with open(events_file, "w") as f:
476
+ f.write(
477
+ json.dumps(
478
+ {
479
+ "event_type": "JobStateChangedEvent",
480
+ "job_id": job_id,
481
+ "state": "running",
482
+ "started_time": time.time(),
483
+ }
484
+ )
485
+ + "\n"
486
+ )
487
+
488
+ time.sleep(1.0)
489
+
490
+ # Should have received job updated event
491
+ job_events = [
492
+ e for e in events_received if isinstance(e, JobStateChangedEvent)
493
+ ]
494
+ assert len(job_events) >= 1, f"Expected job event, got: {events_received}"
495
+ assert any(e.job_id == job_id for e in job_events)
496
+ finally:
497
+ provider.close()
498
+
499
+ def test_detects_job_progress_from_job_events(self, mock_workspace):
500
+ """Should detect job progress updates from job event files"""
501
+ provider = WorkspaceStateProvider(mock_workspace)
502
+
503
+ events_received = []
504
+ provider.add_listener(lambda e: events_received.append(e))
505
+
506
+ try:
507
+ # Create job events file in .events/jobs/{task_id}/event-{job_id}-{count}.jsonl
508
+ task_id = "my.progress.task"
509
+ job_id = "test-job-progress"
510
+ task_dir = mock_workspace / ".events" / "jobs" / task_id
511
+ task_dir.mkdir(parents=True, exist_ok=True)
512
+ events_file = task_dir / f"event-{job_id}-0.jsonl"
513
+
514
+ # Write job progress event
515
+ with open(events_file, "w") as f:
516
+ f.write(
517
+ json.dumps(
518
+ {
519
+ "event_type": "JobProgressEvent",
520
+ "job_id": job_id,
521
+ "level": 0,
522
+ "progress": 0.5,
523
+ "desc": "Halfway done",
524
+ }
525
+ )
526
+ + "\n"
527
+ )
528
+
529
+ time.sleep(1.0)
530
+
531
+ # Should have received job progress event
532
+ job_events = [e for e in events_received if isinstance(e, JobProgressEvent)]
533
+ assert len(job_events) >= 1, (
534
+ f"Expected job progress event, got: {events_received}"
535
+ )
536
+ assert any(e.job_id == job_id for e in job_events)
537
+ finally:
538
+ provider.close()
539
+
540
+ def test_detects_experiment_finalization(self, mock_workspace):
541
+ """Should detect when experiment finalizes (events file deleted)"""
542
+ provider = WorkspaceStateProvider(mock_workspace)
543
+
544
+ events_received = []
545
+ provider.add_listener(lambda e: events_received.append(e))
546
+
547
+ try:
548
+ # Create events file in new subdirectory format
549
+ exp_dir = mock_workspace / ".events" / "experiments" / "v2-multi-run"
550
+ exp_dir.mkdir(parents=True, exist_ok=True)
551
+ events_file = exp_dir / "events-1.jsonl"
552
+
553
+ # Create then delete events file (simulates finalization)
554
+ events_file.touch()
555
+ time.sleep(0.7)
556
+ events_file.unlink()
557
+ time.sleep(0.7)
558
+
559
+ # Should have received experiment updated event
560
+ exp_events = [
561
+ e for e in events_received if isinstance(e, ExperimentUpdatedEvent)
562
+ ]
563
+ assert len(exp_events) >= 1
564
+ assert any(e.experiment_id == "v2-multi-run" for e in exp_events)
565
+ finally:
566
+ provider.close()
567
+
568
+
569
+ # =============================================================================
570
+ # Tests: Tags and Dependencies
571
+ # =============================================================================
572
+
573
+
574
+ class TestTagsAndDependencies:
575
+ """Tests for get_tags_map() and get_dependencies_map()"""
576
+
577
+ def test_get_tags_map(self, mock_workspace):
578
+ """Should return tags for jobs"""
579
+ provider = WorkspaceStateProvider(mock_workspace)
580
+ tags = provider.get_tags_map("v2-multi-run")
581
+
582
+ assert "new-job-1" in tags
583
+ assert tags["new-job-1"]["task"] == "NewTask"
584
+
585
+ def test_get_dependencies_map(self, mock_workspace):
586
+ """Should return dependencies (empty in mock)"""
587
+ provider = WorkspaceStateProvider(mock_workspace)
588
+ deps = provider.get_dependencies_map("v2-multi-run")
589
+
590
+ # Our mock doesn't set dependencies, so should be empty
591
+ assert isinstance(deps, dict)
592
+
593
+
594
+ # =============================================================================
595
+ # Tests: Orphan Job Detection
596
+ # =============================================================================
597
+
598
+
599
+ def create_orphan_job(workspace: Path, task_id: str, job_id: str) -> Path:
600
+ """Create an orphan job (not linked to any experiment)"""
601
+ job_path = workspace / "jobs" / task_id / job_id
602
+ job_path.mkdir(parents=True, exist_ok=True)
603
+
604
+ # Create a .done marker to indicate it's finished
605
+ scriptname = task_id.rsplit(".", 1)[-1]
606
+ (job_path / f"{scriptname}.done").touch()
607
+
608
+ return job_path
609
+
610
+
611
+ class TestOrphanJobDetection:
612
+ """Tests for get_orphan_jobs() method"""
613
+
614
+ def test_no_orphans_in_empty_workspace(self, tmp_path):
615
+ """Empty workspace should have no orphans"""
616
+ workspace = tmp_path / "workspace"
617
+ workspace.mkdir()
618
+
619
+ provider = WorkspaceStateProvider(workspace)
620
+ orphans = provider.get_orphan_jobs()
621
+
622
+ assert len(orphans) == 0
623
+
624
+ def test_v1_jobs_not_detected_as_orphans(self, mock_workspace):
625
+ """Jobs in v1 experiments should NOT be detected as orphans"""
626
+ provider = WorkspaceStateProvider(mock_workspace)
627
+ orphans = provider.get_orphan_jobs()
628
+
629
+ # The mock_workspace has v1-mixed experiment with jobs:
630
+ # job-a1, job-a2 (TaskA), job-b1 (TaskB)
631
+ # These should NOT be in orphans
632
+ orphan_ids = {j.identifier for j in orphans}
633
+
634
+ assert "job-a1" not in orphan_ids, "v1 job-a1 incorrectly detected as orphan"
635
+ assert "job-a2" not in orphan_ids, "v1 job-a2 incorrectly detected as orphan"
636
+ assert "job-b1" not in orphan_ids, "v1 job-b1 incorrectly detected as orphan"
637
+
638
+ def test_v2_jobs_not_detected_as_orphans(self, mock_workspace):
639
+ """Jobs in v2 experiments should NOT be detected as orphans"""
640
+ provider = WorkspaceStateProvider(mock_workspace)
641
+ orphans = provider.get_orphan_jobs()
642
+
643
+ # The mock_workspace has v2-multi-run experiment with jobs in status.json
644
+ # These should NOT be in orphans
645
+ orphan_ids = {j.identifier for j in orphans}
646
+
647
+ # Jobs from v2-multi-run (both runs)
648
+ assert "old-job-1" not in orphan_ids, (
649
+ "v2 old-job-1 incorrectly detected as orphan"
650
+ )
651
+ assert "new-job-1" not in orphan_ids, (
652
+ "v2 new-job-1 incorrectly detected as orphan"
653
+ )
654
+ assert "new-job-2" not in orphan_ids, (
655
+ "v2 new-job-2 incorrectly detected as orphan"
656
+ )
657
+ assert "new-job-3" not in orphan_ids, (
658
+ "v2 new-job-3 incorrectly detected as orphan"
659
+ )
660
+
661
+ # Jobs from v2-failed
662
+ assert "fail-job-1" not in orphan_ids, (
663
+ "v2 fail-job-1 incorrectly detected as orphan"
664
+ )
665
+
666
+ def test_orphan_job_is_detected(self, mock_workspace):
667
+ """Jobs not in any experiment should be detected as orphans"""
668
+ # Create an orphan job
669
+ orphan_path = create_orphan_job(
670
+ mock_workspace, "pkg.OrphanTask", "orphan-job-1"
671
+ )
672
+
673
+ provider = WorkspaceStateProvider(mock_workspace)
674
+ orphans = provider.get_orphan_jobs()
675
+
676
+ orphan_ids = {j.identifier for j in orphans}
677
+ assert "orphan-job-1" in orphan_ids, "Orphan job not detected"
678
+
679
+ # Verify the orphan has correct properties
680
+ orphan = next(j for j in orphans if j.identifier == "orphan-job-1")
681
+ assert orphan.task_id == "pkg.OrphanTask"
682
+ assert orphan.path == orphan_path
683
+
684
+ def test_multiple_orphans_detected(self, mock_workspace):
685
+ """Multiple orphan jobs should all be detected"""
686
+ # Create multiple orphan jobs
687
+ create_orphan_job(mock_workspace, "pkg.Task1", "orphan-1")
688
+ create_orphan_job(mock_workspace, "pkg.Task2", "orphan-2")
689
+ create_orphan_job(mock_workspace, "pkg.Task2", "orphan-3")
690
+
691
+ provider = WorkspaceStateProvider(mock_workspace)
692
+ orphans = provider.get_orphan_jobs()
693
+
694
+ orphan_ids = {j.identifier for j in orphans}
695
+ assert "orphan-1" in orphan_ids
696
+ assert "orphan-2" in orphan_ids
697
+ assert "orphan-3" in orphan_ids
698
+
699
+ def test_experiment_jobs_never_detected_as_orphans(self, mock_workspace):
700
+ """All jobs that exist in experiments should never be orphans"""
701
+ provider = WorkspaceStateProvider(mock_workspace)
702
+
703
+ # Get all jobs from all experiments
704
+ all_experiment_jobs = set()
705
+
706
+ # v1 experiment jobs
707
+ v1_jobs = provider.get_jobs("v1-mixed")
708
+ for j in v1_jobs:
709
+ all_experiment_jobs.add(j.identifier)
710
+
711
+ # v2 experiment jobs (all runs)
712
+ for run in provider.get_experiment_runs("v2-multi-run"):
713
+ jobs = provider.get_jobs("v2-multi-run", run_id=run.run_id)
714
+ for j in jobs:
715
+ all_experiment_jobs.add(j.identifier)
716
+
717
+ # v2 failed experiment jobs
718
+ for run in provider.get_experiment_runs("v2-failed"):
719
+ jobs = provider.get_jobs("v2-failed", run_id=run.run_id)
720
+ for j in jobs:
721
+ all_experiment_jobs.add(j.identifier)
722
+
723
+ # Get orphans
724
+ orphans = provider.get_orphan_jobs()
725
+ orphan_ids = {j.identifier for j in orphans}
726
+
727
+ # Verify no experiment job is in orphans
728
+ intersection = all_experiment_jobs & orphan_ids
729
+ assert len(intersection) == 0, (
730
+ f"Experiment jobs incorrectly detected as orphans: {intersection}"
731
+ )
732
+
733
+ def test_orphan_with_v1_and_v2_mixed(self, tmp_path):
734
+ """Test orphan detection with both v1 and v2 experiments present"""
735
+ workspace = tmp_path / "workspace"
736
+ workspace.mkdir()
737
+
738
+ # Create v1 experiment with one job
739
+ create_v1_experiment(
740
+ workspace,
741
+ "v1-exp",
742
+ jobs=[("pkg.TaskA", "v1-job", "done")],
743
+ )
744
+
745
+ # Create v2 experiment with one job
746
+ create_v2_experiment(
747
+ workspace,
748
+ "v2-exp",
749
+ runs=[
750
+ ("20260101_100000", "completed", [("pkg.TaskB", "v2-job", "done")]),
751
+ ],
752
+ current_run="20260101_100000",
753
+ )
754
+
755
+ # Create two orphan jobs
756
+ create_orphan_job(workspace, "pkg.TaskC", "orphan-1")
757
+ create_orphan_job(workspace, "pkg.TaskD", "orphan-2")
758
+
759
+ provider = WorkspaceStateProvider(workspace)
760
+ orphans = provider.get_orphan_jobs()
761
+ orphan_ids = {j.identifier for j in orphans}
762
+
763
+ # Only the orphans should be detected
764
+ assert len(orphans) == 2
765
+ assert "orphan-1" in orphan_ids
766
+ assert "orphan-2" in orphan_ids
767
+ assert "v1-job" not in orphan_ids
768
+ assert "v2-job" not in orphan_ids