lemming-cli 0.1.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 (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,30 @@
1
+ def test_read_index(client):
2
+ """Test that the root endpoint returns the index.html content."""
3
+ response = client.get("/")
4
+ assert response.status_code == 200
5
+ assert "text/html" in response.headers["content-type"]
6
+ # Verify some content from the index.html file
7
+ assert "Lemming" in response.text
8
+
9
+
10
+ def test_static_files(client):
11
+ """Test that static files are accessible."""
12
+ # Try to access a common static file like mancha.js or index.js
13
+ response = client.get("/static/mancha.js")
14
+ assert response.status_code == 200
15
+ assert "text/javascript" in response.headers["content-type"]
16
+
17
+
18
+ def test_filtered_static_files(client):
19
+ """Test that web test files are filtered out from static assets."""
20
+ # Test file that should be hidden
21
+ response = client.get("/static/dashboard.spec.js")
22
+ assert response.status_code == 404
23
+
24
+ # Test file that should be hidden
25
+ response = client.get("/static/dashboard.test.js")
26
+ assert response.status_code == 404
27
+
28
+ # Normal file should still be accessible
29
+ response = client.get("/static/index.js")
30
+ assert response.status_code == 200
lemming/api/tasks.py ADDED
@@ -0,0 +1,170 @@
1
+ """API routes for creating, updating, and inspecting tasks."""
2
+
3
+ import fastapi
4
+ import pydantic
5
+
6
+ from .. import paths, tasks
7
+ from . import context, loop
8
+
9
+ router = fastapi.APIRouter()
10
+
11
+
12
+ @router.get("/api/data", response_model=tasks.ProjectData)
13
+ def get_data(request: fastapi.Request, project: str | None = None):
14
+ """Get the full project data: context, config, tasks, and status."""
15
+ tasks_file = context.resolve_tasks_file(request.app.state, project)
16
+ data = tasks.get_project_data(tasks_file)
17
+ data.cwd = str(context.resolve_project_dir(request.app.state, project))
18
+ return data
19
+
20
+
21
+ class AddTaskRequest(pydantic.BaseModel):
22
+ """Request body for adding a new task to the roadmap."""
23
+
24
+ description: str
25
+ runner: str | None = None
26
+ index: int = -1
27
+ parent: str | None = None
28
+ parent_tasks_file: str | None = None
29
+
30
+
31
+ @router.post("/api/tasks")
32
+ def add_task(
33
+ request: fastapi.Request, task: AddTaskRequest, project: str | None = None
34
+ ):
35
+ """Add a new task and start the orchestrator loop if needed."""
36
+ tasks_file = context.resolve_tasks_file(request.app.state, project)
37
+ new_task = tasks.add_task(
38
+ tasks_file,
39
+ task.description,
40
+ task.runner,
41
+ index=task.index,
42
+ parent=task.parent,
43
+ parent_tasks_file=task.parent_tasks_file,
44
+ )
45
+ loop.start_loop_if_needed(
46
+ request.app.state,
47
+ tasks_file,
48
+ cwd=context.resolve_project_dir(request.app.state, project),
49
+ )
50
+ return new_task
51
+
52
+
53
+ @router.get("/api/tasks/{task_id}", response_model=tasks.Task)
54
+ def get_task(
55
+ request: fastapi.Request, task_id: str, project: str | None = None
56
+ ):
57
+ """Get a single task by ID (or unique ID prefix)."""
58
+ tasks_file = context.resolve_tasks_file(request.app.state, project)
59
+ data = tasks.load_tasks(tasks_file)
60
+ target = next((t for t in data.tasks if t.id.startswith(task_id)), None)
61
+ if not target:
62
+ raise fastapi.HTTPException(404, "Task not found")
63
+ return target
64
+
65
+
66
+ @router.post("/api/tasks/{task_id}/update")
67
+ def update_task(
68
+ request: fastapi.Request,
69
+ task_id: str,
70
+ update: dict,
71
+ project: str | None = None,
72
+ ):
73
+ """Update a task's description, runner, position, status, or parent."""
74
+ tasks_file = context.resolve_tasks_file(request.app.state, project)
75
+ status = update.get("status")
76
+
77
+ # Validation: require progress if completing or failing from the UI,
78
+ # but not if we are just marking a finished task as pending (uncomplete).
79
+ require_progress = False
80
+ if status in (
81
+ tasks.TaskStatus.COMPLETED,
82
+ tasks.TaskStatus.FAILED,
83
+ tasks.TaskStatus.PENDING,
84
+ ):
85
+ data = tasks.load_tasks(tasks_file)
86
+ target = next((t for t in data.tasks if t.id.startswith(task_id)), None)
87
+ if target and target.status not in (
88
+ tasks.TaskStatus.COMPLETED,
89
+ tasks.TaskStatus.FAILED,
90
+ tasks.TaskStatus.CANCELLED,
91
+ ):
92
+ require_progress = True
93
+
94
+ try:
95
+ updated_task = tasks.update_task(
96
+ tasks_file,
97
+ task_id,
98
+ description=update.get("description"),
99
+ runner=update.get("runner"),
100
+ index=update.get("index"),
101
+ status=status,
102
+ require_progress=require_progress,
103
+ parent=update.get("parent"),
104
+ )
105
+ return updated_task
106
+ except ValueError as e:
107
+ if "not found" in str(e):
108
+ raise fastapi.HTTPException(404, str(e))
109
+ raise fastapi.HTTPException(400, str(e))
110
+
111
+
112
+ @router.post("/api/tasks/delete-completed")
113
+ def delete_completed_tasks(
114
+ request: fastapi.Request, project: str | None = None
115
+ ):
116
+ """Delete all completed tasks from the roadmap."""
117
+ tasks.delete_tasks(
118
+ context.resolve_tasks_file(request.app.state, project),
119
+ completed_only=True,
120
+ )
121
+ return {"status": "ok"}
122
+
123
+
124
+ @router.post("/api/tasks/{task_id}/delete")
125
+ def delete_task(
126
+ request: fastapi.Request, task_id: str, project: str | None = None
127
+ ):
128
+ """Delete a single task by ID."""
129
+ tasks.delete_tasks(
130
+ context.resolve_tasks_file(request.app.state, project), task_id=task_id
131
+ )
132
+ return {"status": "ok"}
133
+
134
+
135
+ @router.post("/api/tasks/{task_id}/cancel")
136
+ def cancel_task_endpoint(
137
+ request: fastapi.Request, task_id: str, project: str | None = None
138
+ ):
139
+ """Cancel a running or pending task."""
140
+ if tasks.cancel_task(
141
+ context.resolve_tasks_file(request.app.state, project), task_id
142
+ ):
143
+ return {"status": "ok"}
144
+ raise fastapi.HTTPException(404, "Task not found")
145
+
146
+
147
+ @router.post("/api/tasks/{task_id}/clear")
148
+ def clear_task_endpoint(
149
+ request: fastapi.Request, task_id: str, project: str | None = None
150
+ ):
151
+ """Reset a task to pending, clearing its attempts and progress."""
152
+ try:
153
+ tasks_file = context.resolve_tasks_file(request.app.state, project)
154
+ tasks.reset_task(tasks_file, task_id)
155
+ return {"status": "ok"}
156
+ except ValueError as e:
157
+ raise fastapi.HTTPException(404, str(e))
158
+
159
+
160
+ @router.get("/api/tasks/{task_id}/log")
161
+ def get_task_log(
162
+ request: fastapi.Request, task_id: str, project: str | None = None
163
+ ):
164
+ """Get the runner log output for a task, or empty if none exists."""
165
+ log_file = paths.get_log_file(
166
+ context.resolve_tasks_file(request.app.state, project), task_id
167
+ )
168
+ if not log_file.exists():
169
+ return {"log": ""}
170
+ return {"log": log_file.read_text(encoding="utf-8")}
@@ -0,0 +1,426 @@
1
+ from unittest.mock import patch
2
+
3
+ from lemming import api, paths, tasks
4
+
5
+
6
+ def test_get_data(client, test_tasks):
7
+ response = client.get("/api/data")
8
+ assert response.status_code == 200
9
+ data = response.json()
10
+ assert data["context"] == "Initial context"
11
+ assert len(data["tasks"]) == 3
12
+ # Check that task1 is in the list
13
+ task1 = next(t for t in data["tasks"] if t["id"] == "task1")
14
+ assert task1["status"] == tasks.TaskStatus.COMPLETED
15
+ assert data["loop_running"] is True
16
+
17
+
18
+ def test_add_task(client, test_tasks):
19
+ response = client.post(
20
+ "/api/tasks", json={"description": "New task from test"}
21
+ )
22
+ assert response.status_code == 200
23
+ data = response.json()
24
+ assert data["description"] == "New task from test"
25
+ assert data["id"] # id should be auto-generated
26
+ assert data["status"] == tasks.TaskStatus.PENDING
27
+
28
+ # Verify task was persisted
29
+ roadmap = tasks.load_tasks(test_tasks)
30
+ assert any(t.description == "New task from test" for t in roadmap.tasks)
31
+
32
+
33
+ def test_add_task_with_runner(client, test_tasks):
34
+ response = client.post(
35
+ "/api/tasks", json={"description": "Runner task", "runner": "claude"}
36
+ )
37
+ assert response.status_code == 200
38
+ assert response.json()["runner"] == "claude"
39
+
40
+
41
+ def test_delete_completed_tasks(client, test_tasks):
42
+ response = client.post("/api/tasks/delete-completed")
43
+ assert response.status_code == 200
44
+ assert response.json() == {"status": "ok"}
45
+
46
+ # Verify tasks in file
47
+ data = tasks.load_tasks(test_tasks)
48
+ task_ids = [t.id for t in data.tasks]
49
+ assert "task1" not in task_ids
50
+ assert "task2" in task_ids
51
+ assert "task3" in task_ids
52
+ assert len(data.tasks) == 2
53
+
54
+
55
+ def test_delete_completed_tasks_includes_failed(client, test_tasks):
56
+ # Setup data with a failed task
57
+ with tasks.lock_tasks(test_tasks):
58
+ data = tasks.load_tasks(test_tasks)
59
+ data.tasks.append(
60
+ tasks.Task(
61
+ id="failed_task",
62
+ description="Failed Task",
63
+ status=tasks.TaskStatus.FAILED,
64
+ attempts=1,
65
+ progress=["Error"],
66
+ completed_at=123456789.0,
67
+ )
68
+ )
69
+ tasks.save_tasks(test_tasks, data)
70
+
71
+ response = client.post("/api/tasks/delete-completed")
72
+ assert response.status_code == 200
73
+
74
+ # Verify tasks in file
75
+ data = tasks.load_tasks(test_tasks)
76
+ task_ids = [t.id for t in data.tasks]
77
+ assert "task1" not in task_ids # task1 is completed
78
+ assert "failed_task" not in task_ids # failed_task should be deleted
79
+ assert "task2" in task_ids
80
+ assert "task3" in task_ids
81
+ assert len(data.tasks) == 2
82
+
83
+
84
+ def test_delete_specific_task(client, test_tasks):
85
+ response = client.post("/api/tasks/task2/delete")
86
+ assert response.status_code == 200
87
+
88
+ data = tasks.load_tasks(test_tasks)
89
+ task_ids = [t.id for t in data.tasks]
90
+ assert "task2" not in task_ids
91
+ assert len(data.tasks) == 2
92
+
93
+
94
+ def test_update_task_description(client, test_tasks):
95
+ response = client.post(
96
+ "/api/tasks/task2/update", json={"description": "Updated Pending Task"}
97
+ )
98
+ assert response.status_code == 200
99
+ assert response.json()["description"] == "Updated Pending Task"
100
+
101
+ data = tasks.load_tasks(test_tasks)
102
+ task2 = next(t for t in data.tasks if t.id == "task2")
103
+ assert task2.description == "Updated Pending Task"
104
+
105
+
106
+ def test_update_completed_task_description_fails(client, test_tasks):
107
+ response = client.post(
108
+ "/api/tasks/task1/update",
109
+ json={"description": "Attempt to update completed task"},
110
+ )
111
+ assert response.status_code == 400
112
+ assert (
113
+ "Cannot edit description of a completed task"
114
+ in response.json()["detail"]
115
+ )
116
+
117
+
118
+ def test_mark_task_failed_via_api(client, test_tasks):
119
+ # task2 is pending with no progress
120
+ # 1. Try to fail without progress -> should fail
121
+ response = client.post(
122
+ "/api/tasks/task2/update", json={"status": tasks.TaskStatus.FAILED}
123
+ )
124
+ assert response.status_code == 400
125
+ assert "has no recorded progress" in response.json()["detail"]
126
+
127
+ # 2. Add progress and try again
128
+ with tasks.lock_tasks(test_tasks):
129
+ data = tasks.load_tasks(test_tasks)
130
+ task2 = next(t for t in data.tasks if t.id == "task2")
131
+ task2.progress = ["Failed attempt"]
132
+ tasks.save_tasks(test_tasks, data)
133
+
134
+ response = client.post(
135
+ "/api/tasks/task2/update", json={"status": tasks.TaskStatus.FAILED}
136
+ )
137
+ assert response.status_code == 200
138
+ assert response.json()["status"] == tasks.TaskStatus.FAILED
139
+
140
+ data = tasks.load_tasks(test_tasks)
141
+ task2 = next(t for t in data.tasks if t.id == "task2")
142
+ assert task2.status == tasks.TaskStatus.FAILED
143
+
144
+
145
+ def test_uncomplete_task_via_api(client, test_tasks):
146
+ # Updating status of a completed task should still be allowed
147
+ response = client.post(
148
+ "/api/tasks/task1/update", json={"status": tasks.TaskStatus.PENDING}
149
+ )
150
+ assert response.status_code == 200
151
+ assert response.json()["status"] == tasks.TaskStatus.PENDING
152
+ assert response.json()["attempts"] == 0
153
+
154
+ data = tasks.load_tasks(test_tasks)
155
+ task1 = next(t for t in data.tasks if t.id == "task1")
156
+ assert task1.status == tasks.TaskStatus.PENDING
157
+ assert task1.attempts == 0
158
+
159
+
160
+ def test_reopen_cancelled_task_via_api(client, test_tasks):
161
+ # First cancel task2 (pending), then reopen it
162
+ response = client.post(
163
+ "/api/tasks/task2/update", json={"status": tasks.TaskStatus.CANCELLED}
164
+ )
165
+ assert response.status_code == 200
166
+ assert response.json()["status"] == tasks.TaskStatus.CANCELLED
167
+
168
+ # Now reopen the cancelled task back to pending
169
+ response = client.post(
170
+ "/api/tasks/task2/update", json={"status": tasks.TaskStatus.PENDING}
171
+ )
172
+ assert response.status_code == 200
173
+ assert response.json()["status"] == tasks.TaskStatus.PENDING
174
+ assert response.json()["attempts"] == 0
175
+
176
+
177
+ def test_has_log_population(client, test_tasks):
178
+ # Initially no logs
179
+ response = client.get("/api/data")
180
+ assert response.status_code == 200
181
+ data = response.json()
182
+ for task in data["tasks"]:
183
+ assert task["has_runner_log"] is False
184
+
185
+ # Create a runner log for task1
186
+ log_file = paths.get_log_file(api.app.state.tasks_file, "task1")
187
+ log_file.write_text("Some logs")
188
+
189
+ response = client.get("/api/data")
190
+ assert response.status_code == 200
191
+ data = response.json()
192
+ task1 = next(t for t in data["tasks"] if t["id"] == "task1")
193
+ assert task1["has_runner_log"] is True
194
+
195
+ task2 = next(t for t in data["tasks"] if t["id"] == "task2")
196
+ assert task2["has_runner_log"] is False
197
+
198
+
199
+ def test_get_single_task(client, test_tasks):
200
+ # task1 exists in test_tasks fixture
201
+ resp = client.get("/api/tasks/task1")
202
+ assert resp.status_code == 200
203
+ assert resp.json()["id"] == "task1"
204
+ assert resp.json()["description"] == "Completed Task"
205
+
206
+
207
+ def test_get_nonexistent_task(client, test_tasks):
208
+ resp = client.get("/api/tasks/nonexistent")
209
+ assert resp.status_code == 404
210
+
211
+
212
+ def test_api_log(client, test_tasks):
213
+ test_tasks_file = test_tasks
214
+ task_id = "testlogid"
215
+ log_file = paths.get_log_file(test_tasks_file, task_id)
216
+ log_file.write_text("API log content")
217
+
218
+ response = client.get(f"/api/tasks/{task_id}/log")
219
+ assert response.status_code == 200
220
+ assert response.json() == {"log": "API log content"}
221
+
222
+ # Test non-existent log
223
+ response = client.get("/api/tasks/missing/log")
224
+ assert response.status_code == 200
225
+ assert response.json() == {"log": ""}
226
+
227
+
228
+ def test_api_delete_log_cleanup(client, test_tasks):
229
+ test_tasks_file = test_tasks
230
+ # 1. Add a task
231
+ data = tasks.load_tasks(test_tasks_file)
232
+ task_id = "api_delete_test"
233
+ data.tasks.append(
234
+ tasks.Task(
235
+ id=task_id,
236
+ description="api delete test",
237
+ status=tasks.TaskStatus.PENDING,
238
+ attempts=0,
239
+ progress=[],
240
+ )
241
+ )
242
+ tasks.save_tasks(test_tasks_file, data)
243
+
244
+ # 2. Create log manually
245
+ log_file = paths.get_log_file(test_tasks_file, task_id)
246
+ log_file.write_text("API delete log")
247
+ assert log_file.exists()
248
+
249
+ # 3. Delete via API
250
+ response = client.post(f"/api/tasks/{task_id}/delete")
251
+ assert response.status_code == 200
252
+ assert not log_file.exists()
253
+
254
+
255
+ def test_project_param_get_data(client, test_tasks):
256
+ """GET /api/data?project=subdir returns data for that project."""
257
+ root = api.app.state.root
258
+ subdir = root / "myproject"
259
+ subdir.mkdir(exist_ok=True)
260
+
261
+ # Add a task via the project param
262
+ response = client.post(
263
+ "/api/tasks",
264
+ json={"description": "Sub-project task"},
265
+ params={"project": "myproject"},
266
+ )
267
+ assert response.status_code == 200
268
+
269
+ # Fetch data for the sub-project
270
+ response = client.get("/api/data", params={"project": "myproject"})
271
+ assert response.status_code == 200
272
+ data = response.json()
273
+ assert any(t["description"] == "Sub-project task" for t in data["tasks"])
274
+
275
+ # Default project should not have this task
276
+ response = client.get("/api/data")
277
+ assert response.status_code == 200
278
+ data = response.json()
279
+ assert not any(
280
+ t["description"] == "Sub-project task" for t in data["tasks"]
281
+ )
282
+
283
+
284
+ def test_project_delete_completed_isolation(client, test_tasks):
285
+ """Deleting completed tasks in root does not affect sub-projects."""
286
+ root = api.app.state.root
287
+ subdir = root / "isolated"
288
+ subdir.mkdir(exist_ok=True)
289
+
290
+ # Add and complete a task in the sub-project
291
+ res = client.post(
292
+ "/api/tasks",
293
+ json={"description": "Sub task"},
294
+ params={"project": "isolated"},
295
+ )
296
+ assert res.status_code == 200
297
+ task_id = res.json()["id"]
298
+ # Mark it completed (requires progress, so use update_task directly)
299
+ tasks.update_task(
300
+ paths.get_tasks_file_for_dir(subdir),
301
+ task_id,
302
+ status=tasks.TaskStatus.COMPLETED,
303
+ require_progress=False,
304
+ )
305
+
306
+ # Delete completed in ROOT
307
+ response = client.post("/api/tasks/delete-completed")
308
+ assert response.status_code == 200
309
+
310
+ # Sub-project's completed task should still exist
311
+ res = client.get("/api/data", params={"project": "isolated"})
312
+ assert res.status_code == 200
313
+ assert any(t["id"] == task_id for t in res.json()["tasks"])
314
+
315
+
316
+ def test_add_task_auto_starts_loop(client, test_tasks):
317
+ with patch("subprocess.Popen") as mock_popen:
318
+ # Mock is_loop_running to return False
319
+ with patch("lemming.tasks.is_loop_running", return_value=False):
320
+ # Set auto-start to True for this test
321
+ api.app.state.disable_auto_start = False
322
+ try:
323
+ response = client.post(
324
+ "/api/tasks", json={"description": "New task"}
325
+ )
326
+ assert response.status_code == 200
327
+
328
+ # Verify Popen was called to start the loop
329
+ mock_popen.assert_called_once()
330
+ args, kwargs = mock_popen.call_args
331
+ cmd = args[0]
332
+ assert "run" in cmd
333
+ assert str(test_tasks) in cmd
334
+ finally:
335
+ api.app.state.disable_auto_start = True
336
+
337
+
338
+ def test_add_task_does_not_restart_if_running(client, test_tasks):
339
+ with patch("subprocess.Popen") as mock_popen:
340
+ # Mock is_loop_running to return True
341
+ with patch("lemming.tasks.is_loop_running", return_value=True):
342
+ response = client.post(
343
+ "/api/tasks", json={"description": "New task"}
344
+ )
345
+ assert response.status_code == 200
346
+
347
+ # Verify Popen was NOT called
348
+ mock_popen.assert_not_called()
349
+
350
+
351
+ def test_update_task_to_pending_does_not_start_loop(client, test_tasks):
352
+ # Add a completed task
353
+ task = tasks.add_task(test_tasks, "Completed task")
354
+ tasks.add_progress(test_tasks, task.id, "Done")
355
+ tasks.update_task(test_tasks, task.id, status=tasks.TaskStatus.COMPLETED)
356
+
357
+ with patch("subprocess.Popen") as mock_popen:
358
+ # Mock is_loop_running to return False
359
+ with patch("lemming.tasks.is_loop_running", return_value=False):
360
+ # Update to pending
361
+ response = client.post(
362
+ f"/api/tasks/{task.id}/update",
363
+ json={"status": tasks.TaskStatus.PENDING},
364
+ )
365
+ assert response.status_code == 200
366
+
367
+ # Verify Popen was NOT called
368
+ mock_popen.assert_not_called()
369
+
370
+
371
+ def test_clear_task_does_not_start_loop(client, test_tasks):
372
+ # Add a completed task
373
+ task = tasks.add_task(test_tasks, "Completed task")
374
+ tasks.add_progress(test_tasks, task.id, "Done")
375
+ tasks.update_task(test_tasks, task.id, status=tasks.TaskStatus.COMPLETED)
376
+
377
+ with patch("subprocess.Popen") as mock_popen:
378
+ # Mock is_loop_running to return False
379
+ with patch("lemming.tasks.is_loop_running", return_value=False):
380
+ # Clear task
381
+ response = client.post(f"/api/tasks/{task.id}/clear")
382
+ assert response.status_code == 200
383
+
384
+ # Verify Popen was NOT called
385
+ mock_popen.assert_not_called()
386
+
387
+
388
+ def test_add_task_respects_disable_auto_start(client, test_tasks):
389
+ api.app.state.disable_auto_start = True
390
+ with patch("subprocess.Popen") as mock_popen:
391
+ with patch("lemming.tasks.is_loop_running", return_value=False):
392
+ response = client.post(
393
+ "/api/tasks", json={"description": "No auto-start task"}
394
+ )
395
+ assert response.status_code == 200
396
+
397
+ # Verify Popen was NOT called
398
+ mock_popen.assert_not_called()
399
+
400
+
401
+ def test_add_task_starts_loop_with_cwd(test_workspace, client):
402
+ root_dir, subproject_dir = test_workspace
403
+
404
+ with patch("subprocess.Popen") as mock_popen:
405
+ # Adding a task should trigger auto-start of the loop
406
+ response = client.post(
407
+ "/api/tasks",
408
+ json={"description": "New task in subproject"},
409
+ params={"project": "my-subproject"},
410
+ )
411
+ assert response.status_code == 200
412
+
413
+ # Verify Popen was called with the correct cwd for the auto-started loop
414
+ _, kwargs = mock_popen.call_args
415
+ assert str(kwargs["cwd"]) == str(subproject_dir)
416
+
417
+
418
+ def test_cancel_task_endpoint(client, test_tasks):
419
+ with patch("lemming.tasks.cancel_task", return_value=True):
420
+ response = client.post("/api/tasks/task3/cancel")
421
+ assert response.status_code == 200
422
+ assert response.json() == {"status": "ok"}
423
+
424
+ with patch("lemming.tasks.cancel_task", return_value=False):
425
+ response = client.post("/api/tasks/nonexistent/cancel")
426
+ assert response.status_code == 404
lemming/api.py ADDED
@@ -0,0 +1,5 @@
1
+ """Backward-compatible entry point re-exporting the FastAPI app."""
2
+
3
+ from .api.main import QuietPollFilter, app
4
+
5
+ __all__ = ["app", "QuietPollFilter"]
lemming/api_test.py ADDED
@@ -0,0 +1,7 @@
1
+ from lemming import api
2
+
3
+
4
+ def test_api_proxy():
5
+ # Verify that the proxy re-exports work
6
+ assert hasattr(api, "app")
7
+ assert hasattr(api, "QuietPollFilter")
@@ -0,0 +1,12 @@
1
+ """Lemming CLI package; importing the submodules registers all commands."""
2
+
3
+ from . import config as _config_cmds # noqa: F401
4
+ from . import context as _context_cmds # noqa: F401
5
+ from . import hooks as _hooks_cmds # noqa: F401
6
+ from . import operations as _ops_cmds # noqa: F401
7
+ from . import progress as _progress_cmds # noqa: F401
8
+ from . import readability_cli as _readability_cmds # noqa: F401
9
+ from . import tasks as _tasks_cmds # noqa: F401
10
+ from .main import cli
11
+
12
+ __all__ = ["cli"]