lemming-cli 0.3.0__tar.gz → 0.3.1__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 (132) hide show
  1. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/PKG-INFO +1 -1
  2. lemming_cli-0.3.1/docs/screenshots/dashboard-desktop.png +0 -0
  3. lemming_cli-0.3.1/docs/screenshots/dashboard-mobile.png +0 -0
  4. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/pyproject.toml +1 -1
  5. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/models.py +2 -0
  6. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/models_test.py +2 -0
  7. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/runner.py +9 -0
  8. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/runner_test.py +2 -0
  9. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/tasks/__init__.py +3 -0
  10. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/tasks/lifecycle.py +36 -0
  11. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/tasks/lifecycle_test.py +21 -0
  12. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/tasks/operations.py +6 -0
  13. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/dashboard.spec.js +37 -0
  14. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/dashboard.test.js +46 -1
  15. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/index.js +15 -1
  16. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/uv.lock +1 -1
  17. lemming_cli-0.3.0/docs/screenshots/dashboard-desktop.png +0 -0
  18. lemming_cli-0.3.0/docs/screenshots/dashboard-mobile.png +0 -0
  19. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/.github/workflows/ci.yml +0 -0
  20. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/.github/workflows/publish.yml +0 -0
  21. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/.github/workflows/screenshots.yml +0 -0
  22. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/.gitignore +0 -0
  23. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/.prettierignore +0 -0
  24. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/.prettierrc +0 -0
  25. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/Dockerfile +0 -0
  26. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/LICENSE +0 -0
  27. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/README.md +0 -0
  28. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/biome.json +0 -0
  29. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/docker-compose.yml +0 -0
  30. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/docs/EVALS.md +0 -0
  31. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/docs/HOOKS.md +0 -0
  32. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/docs/screenshots/task-log-desktop.png +0 -0
  33. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/docs/screenshots/task-log-mobile.png +0 -0
  34. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/package-lock.json +0 -0
  35. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/package.json +0 -0
  36. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/playwright.config.js +0 -0
  37. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/__init__.py +0 -0
  38. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/__init__.py +0 -0
  39. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/auth.py +0 -0
  40. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/auth_test.py +0 -0
  41. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/config.py +0 -0
  42. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/config_test.py +0 -0
  43. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/conftest.py +0 -0
  44. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/context.py +0 -0
  45. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/context_test.py +0 -0
  46. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/directories.py +0 -0
  47. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/directories_test.py +0 -0
  48. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/files.py +0 -0
  49. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/files_test.py +0 -0
  50. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/hooks.py +0 -0
  51. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/hooks_test.py +0 -0
  52. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/logging.py +0 -0
  53. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/logging_test.py +0 -0
  54. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/loop.py +0 -0
  55. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/loop_test.py +0 -0
  56. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/main.py +0 -0
  57. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/main_test.py +0 -0
  58. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/tasks.py +0 -0
  59. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api/tasks_test.py +0 -0
  60. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/api_test.py +0 -0
  61. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/__init__.py +0 -0
  62. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/config.py +0 -0
  63. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/config_test.py +0 -0
  64. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/goal.py +0 -0
  65. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/goal_test.py +0 -0
  66. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/hooks.py +0 -0
  67. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/hooks_test.py +0 -0
  68. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/main.py +0 -0
  69. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/main_test.py +0 -0
  70. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/operations.py +0 -0
  71. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/operations_test.py +0 -0
  72. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/progress.py +0 -0
  73. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/progress_test.py +0 -0
  74. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/readability_cli.py +0 -0
  75. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/readability_cli_test.py +0 -0
  76. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/tasks.py +0 -0
  77. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli/tasks_test.py +0 -0
  78. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/cli_test.py +0 -0
  79. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/conftest.py +0 -0
  80. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/__init__.py +0 -0
  81. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/__main__.py +0 -0
  82. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/cli.py +0 -0
  83. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/cli_test.py +0 -0
  84. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/container.py +0 -0
  85. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/container_test.py +0 -0
  86. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/fixtures.py +0 -0
  87. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/fixtures_test.py +0 -0
  88. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/harness.py +0 -0
  89. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/harness_test.py +0 -0
  90. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/readability.py +0 -0
  91. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/readability_test.py +0 -0
  92. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/roadmap.py +0 -0
  93. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/roadmap_test.py +0 -0
  94. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/scenarios.py +0 -0
  95. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/scenarios_test.py +0 -0
  96. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/suites.py +0 -0
  97. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/trial.py +0 -0
  98. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/evals/trial_test.py +0 -0
  99. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/hooks.py +0 -0
  100. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/hooks_test.py +0 -0
  101. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/integration_test.py +0 -0
  102. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/main.py +0 -0
  103. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/main_test.py +0 -0
  104. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/orchestrator.py +0 -0
  105. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/orchestrator_test.py +0 -0
  106. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/paths.py +0 -0
  107. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/paths_test.py +0 -0
  108. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/persistence.py +0 -0
  109. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/persistence_test.py +0 -0
  110. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/prompts/hooks/50-readability.md +0 -0
  111. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/prompts/hooks/60-testing.md +0 -0
  112. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/prompts/hooks/70-ux.md +0 -0
  113. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/prompts/hooks/90-roadmap.md +0 -0
  114. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/prompts/taskrunner.md +0 -0
  115. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/prompts.py +0 -0
  116. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/prompts_test.py +0 -0
  117. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/providers.py +0 -0
  118. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/providers_test.py +0 -0
  119. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/tasks/operations_test.py +0 -0
  120. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/tasks/progress.py +0 -0
  121. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/tasks/progress_test.py +0 -0
  122. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/tasks/queries.py +0 -0
  123. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/tasks/queries_test.py +0 -0
  124. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/favicon.js +0 -0
  125. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/favicon.spec.js +0 -0
  126. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/files.html +0 -0
  127. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/files.spec.js +0 -0
  128. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/index.html +0 -0
  129. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/logs.html +0 -0
  130. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/logs.test.js +0 -0
  131. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/mancha.js +0 -0
  132. {lemming_cli-0.3.0 → lemming_cli-0.3.1}/src/lemming/web/screenshots.spec.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lemming-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: An autonomous, iterative task runner for AI coding agents.
5
5
  Project-URL: Homepage, https://github.com/owahltinez/lemming
6
6
  Project-URL: Repository, https://github.com/owahltinez/lemming
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lemming-cli"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "An autonomous, iterative task runner for AI coding agents."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -60,6 +60,8 @@ class Task(pydantic.BaseModel):
60
60
  created_at: float = pydantic.Field(default_factory=time.time)
61
61
  run_time: float = 0.0
62
62
  execution_times: dict[str, float] | None = None
63
+ active_execution_component: str | None = None
64
+ active_execution_started_at: float | None = None
63
65
  pid: int | None = None
64
66
  last_heartbeat: float | None = None
65
67
  has_runner_log: bool = False
@@ -19,6 +19,8 @@ def test_task_model_defaults():
19
19
  assert task.progress == []
20
20
  assert task.run_time == 0.0
21
21
  assert task.execution_times is None
22
+ assert task.active_execution_component is None
23
+ assert task.active_execution_started_at is None
22
24
  assert task.superseded_at is None
23
25
  assert task.superseded_reason is None
24
26
  assert task.created_at > 0
@@ -306,6 +306,15 @@ def run_with_heartbeat(
306
306
  if header and header.startswith("Hook: ")
307
307
  else "runner"
308
308
  )
309
+ try:
310
+ tasks.mark_execution_started(tasks_file, task_id, component)
311
+ except Exception:
312
+ logger.warning(
313
+ "Could not mark %s execution as active for task %s",
314
+ component,
315
+ task_id,
316
+ exc_info=True,
317
+ )
309
318
 
310
319
  full_log: list[str] = []
311
320
 
@@ -302,6 +302,8 @@ def test_run_with_heartbeat_records_runner_and_hook_times(tmp_path):
302
302
  assert task.execution_times is not None
303
303
  assert task.execution_times["runner"] > 0
304
304
  assert task.execution_times["hook:testing"] > 0
305
+ assert task.active_execution_component is None
306
+ assert task.active_execution_started_at is None
305
307
 
306
308
 
307
309
  def test_run_with_heartbeat_interruption_cleanup(tmp_path):
@@ -25,6 +25,9 @@ from .lifecycle import (
25
25
  from .lifecycle import (
26
26
  is_task_active as is_task_active,
27
27
  )
28
+ from .lifecycle import (
29
+ mark_execution_started as mark_execution_started,
30
+ )
28
31
  from .lifecycle import (
29
32
  mark_task_in_progress as mark_task_in_progress,
30
33
  )
@@ -83,6 +83,34 @@ def record_execution_time(
83
83
  task.execution_times[component] = (
84
84
  task.execution_times.get(component, 0.0) + duration
85
85
  )
86
+ if task.active_execution_component == component:
87
+ task.active_execution_component = None
88
+ task.active_execution_started_at = None
89
+ persistence.save_tasks(tasks_file, data)
90
+
91
+
92
+ def mark_execution_started(
93
+ tasks_file: pathlib.Path,
94
+ task_id: str,
95
+ component: str,
96
+ started_at: float | None = None,
97
+ ) -> None:
98
+ """Records the component whose elapsed time is currently accumulating.
99
+
100
+ Args:
101
+ tasks_file: Path to the tasks YAML file.
102
+ task_id: ID of the task whose subprocess started.
103
+ component: Stable component key, such as ``runner``.
104
+ started_at: Optional wall-clock timestamp, defaulting to now.
105
+ """
106
+ with persistence.lock_tasks(tasks_file):
107
+ data = persistence.load_tasks(tasks_file)
108
+ task = next((t for t in data.tasks if t.id == task_id), None)
109
+ if task is None:
110
+ return
111
+
112
+ task.active_execution_component = component
113
+ task.active_execution_started_at = started_at or time.time()
86
114
  persistence.save_tasks(tasks_file, data)
87
115
 
88
116
 
@@ -141,6 +169,8 @@ def _mark_task_in_progress(
141
169
  if task.started_at is None:
142
170
  task.started_at = now
143
171
  task.last_started_at = now
172
+ task.active_execution_component = None
173
+ task.active_execution_started_at = None
144
174
  if pid is not None:
145
175
  task.pid = pid
146
176
  return True
@@ -259,6 +289,8 @@ def revert_task_to_pending(
259
289
  task.requested_status = None
260
290
  task.pid = None
261
291
  task.last_heartbeat = None
292
+ task.active_execution_component = None
293
+ task.active_execution_started_at = None
262
294
 
263
295
  persistence.save_tasks(tasks_file, data)
264
296
  return task
@@ -376,6 +408,8 @@ def cancel_task(tasks_file: pathlib.Path, task_id: str) -> bool:
376
408
  task.pid = None
377
409
  task.last_heartbeat = None
378
410
  task.requested_status = None
411
+ task.active_execution_component = None
412
+ task.active_execution_started_at = None
379
413
 
380
414
  persistence.save_tasks(tasks_file, data)
381
415
 
@@ -414,6 +448,8 @@ def reset_task(tasks_file: pathlib.Path, task_id: str) -> models.Task:
414
448
  target.progress = []
415
449
  target.run_time = 0.0
416
450
  target.execution_times = None
451
+ target.active_execution_component = None
452
+ target.active_execution_started_at = None
417
453
  target.completed_at = None
418
454
  target.superseded_at = None
419
455
  target.superseded_reason = None
@@ -61,6 +61,27 @@ def test_record_execution_time_accumulates_by_component(tmp_path):
61
61
  }
62
62
 
63
63
 
64
+ def test_active_execution_is_recorded_then_cleared(tmp_path):
65
+ tasks_file = tmp_path / "tasks.yml"
66
+ persistence.save_tasks(
67
+ tasks_file,
68
+ models.Roadmap(tasks=[models.Task(id="1", description="test")]),
69
+ )
70
+
71
+ lifecycle.mark_execution_started(
72
+ tasks_file, "1", "hook:testing", started_at=123.0
73
+ )
74
+ active_task = persistence.load_tasks(tasks_file).tasks[0]
75
+ assert active_task.active_execution_component == "hook:testing"
76
+ assert active_task.active_execution_started_at == 123.0
77
+
78
+ lifecycle.record_execution_time(tasks_file, "1", "hook:testing", 5.0)
79
+ finished_task = persistence.load_tasks(tasks_file).tasks[0]
80
+ assert finished_task.execution_times == {"hook:testing": 5.0}
81
+ assert finished_task.active_execution_component is None
82
+ assert finished_task.active_execution_started_at is None
83
+
84
+
64
85
  def test_mark_task_in_progress(tmp_path):
65
86
  tasks_file = tmp_path / "tasks.yml"
66
87
  data = models.Roadmap(
@@ -216,6 +216,8 @@ def supersede_task(
216
216
  target.pid = None
217
217
  target.last_heartbeat = None
218
218
  target.requested_status = None
219
+ target.active_execution_component = None
220
+ target.active_execution_started_at = None
219
221
 
220
222
  persistence.save_tasks(tasks_file, data)
221
223
  return target
@@ -320,12 +322,16 @@ def update_task(
320
322
  target.pid = None
321
323
  target.last_heartbeat = None
322
324
  target.requested_status = None
325
+ target.active_execution_component = None
326
+ target.active_execution_started_at = None
323
327
  elif status == models.TaskStatus.PENDING:
324
328
  target.completed_at = None
325
329
  target.superseded_at = None
326
330
  target.superseded_reason = None
327
331
  target.attempts = 0
328
332
  target.requested_status = None
333
+ target.active_execution_component = None
334
+ target.active_execution_started_at = None
329
335
  elif target.completed_at is not None:
330
336
  target.completed_at = None
331
337
 
@@ -163,6 +163,43 @@ test.describe('Dashboard E2E', () => {
163
163
  expect(new Set(measurements.map((segment) => segment.color)).size).toBe(3);
164
164
  });
165
165
 
166
+ test('advances the active execution segment while a task is running', async ({
167
+ page,
168
+ }) => {
169
+ const activeStartedAt = Date.now() / 1000 - 5;
170
+ await page.route('**/api/data', async (route) => {
171
+ await route.fulfill({
172
+ contentType: 'application/json',
173
+ json: {
174
+ cwd: '/mock/cwd',
175
+ loop_running: true,
176
+ goal: 'Live timing project',
177
+ tasks: [
178
+ {
179
+ id: 'running',
180
+ description: 'Task with live execution timing',
181
+ status: 'in_progress',
182
+ attempts: 1,
183
+ progress: [],
184
+ active_execution_component: 'runner',
185
+ active_execution_started_at: activeStartedAt,
186
+ },
187
+ ],
188
+ config: { retries: 3, runner: 'agy', time_limit: 60 },
189
+ },
190
+ });
191
+ });
192
+
193
+ await gotoAndAwaitMancha(page);
194
+ await page.getByRole('button', { name: 'Show details' }).click();
195
+
196
+ const timingBar = page.getByTestId('execution-breakdown').getByRole('img');
197
+ const initialLabel = await timingBar.getAttribute('aria-label');
198
+ await expect
199
+ .poll(() => timingBar.getAttribute('aria-label'))
200
+ .not.toBe(initialLabel);
201
+ });
202
+
166
203
  // --- Environment Overrides Tests ---
167
204
 
168
205
  test('adds an environment override, stores it in localStorage, and sends it on run', async ({
@@ -98,7 +98,21 @@ const createInitialState = (overrides = {}) => {
98
98
  'bg-purple-600',
99
99
  'bg-red-600',
100
100
  ];
101
- const entries = Object.entries(task.execution_times || {}).filter(
101
+ const executionTimes = { ...(task.execution_times || {}) };
102
+ if (
103
+ task.status === 'in_progress' &&
104
+ task.active_execution_component &&
105
+ task.active_execution_started_at
106
+ ) {
107
+ const activeDuration = Math.max(
108
+ 0,
109
+ Date.now() / 1000 - task.active_execution_started_at,
110
+ );
111
+ executionTimes[task.active_execution_component] =
112
+ Number(executionTimes[task.active_execution_component] || 0) +
113
+ activeDuration;
114
+ }
115
+ const entries = Object.entries(executionTimes).filter(
102
116
  ([, duration]) => Number.isFinite(Number(duration)) && duration > 0,
103
117
  );
104
118
  entries.sort(([a], [b]) => {
@@ -894,6 +908,37 @@ describe('Lemming Web Dashboard', () => {
894
908
  assert.ok(breakdown.textContent.includes('testing 20s'));
895
909
  });
896
910
 
911
+ test('adds live elapsed time to the active execution segment', () => {
912
+ const originalDateNow = Date.now;
913
+ let now = 1_000_000;
914
+ Date.now = () => now;
915
+ try {
916
+ const task = {
917
+ status: 'in_progress',
918
+ execution_times: { runner: 30 },
919
+ active_execution_component: 'hook:testing',
920
+ active_execution_started_at: 990,
921
+ };
922
+ const initialState = createInitialState();
923
+
924
+ let segments = initialState.getExecutionSegments(task);
925
+ assert.deepStrictEqual(
926
+ segments.map(({ key, duration }) => [key, duration]),
927
+ [
928
+ ['runner', 30],
929
+ ['hook:testing', 10],
930
+ ],
931
+ );
932
+
933
+ now += 5_000;
934
+ segments = initialState.getExecutionSegments(task);
935
+ assert.strictEqual(segments[1].duration, 15);
936
+ assert.ok(Math.abs(segments[1].percent - 100 / 3) < 0.001);
937
+ } finally {
938
+ Date.now = originalDateNow;
939
+ }
940
+ });
941
+
897
942
  test('hides execution breakdown when timing data is unavailable', async () => {
898
943
  const tasks = [
899
944
  {
@@ -193,7 +193,21 @@
193
193
  'bg-purple-600',
194
194
  'bg-red-600',
195
195
  ];
196
- const entries = Object.entries(task.execution_times || {}).filter(
196
+ const executionTimes = { ...(task.execution_times || {}) };
197
+ if (
198
+ task.status === 'in_progress' &&
199
+ task.active_execution_component &&
200
+ task.active_execution_started_at
201
+ ) {
202
+ const activeDuration = Math.max(
203
+ 0,
204
+ Date.now() / 1000 - task.active_execution_started_at,
205
+ );
206
+ executionTimes[task.active_execution_component] =
207
+ Number(executionTimes[task.active_execution_component] || 0) +
208
+ activeDuration;
209
+ }
210
+ const entries = Object.entries(executionTimes).filter(
197
211
  ([, duration]) => Number.isFinite(Number(duration)) && duration > 0,
198
212
  );
199
213
  entries.sort(([a], [b]) => {
@@ -222,7 +222,7 @@ wheels = [
222
222
 
223
223
  [[package]]
224
224
  name = "lemming-cli"
225
- version = "0.3.0"
225
+ version = "0.3.1"
226
226
  source = { editable = "." }
227
227
  dependencies = [
228
228
  { name = "beautifulsoup4" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes