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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lemming/api/main_test.py
ADDED
|
@@ -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
lemming/api_test.py
ADDED
lemming/cli/__init__.py
ADDED
|
@@ -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"]
|