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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Task CRUD operations: add, update, delete, and context updates."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .. import models, persistence
|
|
8
|
+
from . import lifecycle
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_task(
|
|
12
|
+
tasks_file: pathlib.Path,
|
|
13
|
+
description: str,
|
|
14
|
+
runner: str | None = None,
|
|
15
|
+
index: int = -1,
|
|
16
|
+
parent: str | None = None,
|
|
17
|
+
parent_tasks_file: str | None = None,
|
|
18
|
+
) -> models.Task:
|
|
19
|
+
"""Adds a new task to the roadmap.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
tasks_file: Path to the tasks YAML file.
|
|
23
|
+
description: Description of the task.
|
|
24
|
+
runner: Optional preferred runner for this task.
|
|
25
|
+
index: Position to insert the task at (default: append).
|
|
26
|
+
parent: Optional parent task ID.
|
|
27
|
+
parent_tasks_file: Optional parent tasks file path.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The newly created Task.
|
|
31
|
+
"""
|
|
32
|
+
with persistence.lock_tasks(tasks_file):
|
|
33
|
+
data = persistence.load_tasks(tasks_file)
|
|
34
|
+
|
|
35
|
+
task_id = lifecycle.generate_task_id()
|
|
36
|
+
existing_ids = {t.id for t in data.tasks}
|
|
37
|
+
while task_id in existing_ids:
|
|
38
|
+
task_id = lifecycle.generate_task_id()
|
|
39
|
+
|
|
40
|
+
# Detect if we are running inside an agent and set parent automatically
|
|
41
|
+
if not parent:
|
|
42
|
+
parent = os.environ.get("LEMMING_PARENT_TASK_ID")
|
|
43
|
+
if not parent_tasks_file:
|
|
44
|
+
parent_tasks_file = os.environ.get("LEMMING_PARENT_TASKS_FILE")
|
|
45
|
+
|
|
46
|
+
new_task = models.Task(
|
|
47
|
+
id=task_id,
|
|
48
|
+
description=description,
|
|
49
|
+
runner=runner,
|
|
50
|
+
parent=parent,
|
|
51
|
+
parent_tasks_file=parent_tasks_file,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if index == -1:
|
|
55
|
+
data.tasks.append(new_task)
|
|
56
|
+
else:
|
|
57
|
+
data.tasks.insert(index, new_task)
|
|
58
|
+
|
|
59
|
+
persistence.save_tasks(tasks_file, data)
|
|
60
|
+
return new_task
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def delete_tasks(
|
|
64
|
+
tasks_file: pathlib.Path,
|
|
65
|
+
task_id: str | None = None,
|
|
66
|
+
all_tasks: bool = False,
|
|
67
|
+
completed_only: bool = False,
|
|
68
|
+
) -> int:
|
|
69
|
+
"""Deletes tasks from the roadmap.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
tasks_file: Path to the tasks YAML file.
|
|
73
|
+
task_id: Optional ID of a specific task to delete.
|
|
74
|
+
all_tasks: If True, deletes all tasks and clears context.
|
|
75
|
+
completed_only: If True, deletes only completed tasks.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The number of tasks deleted.
|
|
79
|
+
"""
|
|
80
|
+
with persistence.lock_tasks(tasks_file):
|
|
81
|
+
data = persistence.load_tasks(tasks_file)
|
|
82
|
+
initial_count = len(data.tasks)
|
|
83
|
+
|
|
84
|
+
if all_tasks:
|
|
85
|
+
for t in data.tasks:
|
|
86
|
+
lifecycle.reset_task_logs(tasks_file, t.id)
|
|
87
|
+
data.tasks = []
|
|
88
|
+
data.context = ""
|
|
89
|
+
elif completed_only:
|
|
90
|
+
completed_tasks = [
|
|
91
|
+
t
|
|
92
|
+
for t in data.tasks
|
|
93
|
+
if t.status
|
|
94
|
+
in (
|
|
95
|
+
models.TaskStatus.COMPLETED,
|
|
96
|
+
models.TaskStatus.FAILED,
|
|
97
|
+
models.TaskStatus.CANCELLED,
|
|
98
|
+
)
|
|
99
|
+
]
|
|
100
|
+
for t in completed_tasks:
|
|
101
|
+
lifecycle.reset_task_logs(tasks_file, t.id)
|
|
102
|
+
data.tasks = [
|
|
103
|
+
t
|
|
104
|
+
for t in data.tasks
|
|
105
|
+
if t.status
|
|
106
|
+
not in (
|
|
107
|
+
models.TaskStatus.COMPLETED,
|
|
108
|
+
models.TaskStatus.FAILED,
|
|
109
|
+
models.TaskStatus.CANCELLED,
|
|
110
|
+
)
|
|
111
|
+
]
|
|
112
|
+
elif task_id:
|
|
113
|
+
tasks_to_delete = [
|
|
114
|
+
t for t in data.tasks if t.id.startswith(task_id)
|
|
115
|
+
]
|
|
116
|
+
for t in tasks_to_delete:
|
|
117
|
+
lifecycle.reset_task_logs(tasks_file, t.id)
|
|
118
|
+
data.tasks = [t for t in data.tasks if not t.id.startswith(task_id)]
|
|
119
|
+
|
|
120
|
+
persistence.save_tasks(tasks_file, data)
|
|
121
|
+
return initial_count - len(data.tasks)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def update_task(
|
|
125
|
+
tasks_file: pathlib.Path,
|
|
126
|
+
task_id: str,
|
|
127
|
+
description: str | None = None,
|
|
128
|
+
runner: str | None = None,
|
|
129
|
+
index: int | None = None,
|
|
130
|
+
status: str | None = None,
|
|
131
|
+
require_progress: bool = False,
|
|
132
|
+
parent: str | None = None,
|
|
133
|
+
parent_tasks_file: str | None = None,
|
|
134
|
+
force: bool = False,
|
|
135
|
+
) -> models.Task:
|
|
136
|
+
"""Updates an existing task.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
tasks_file: Path to the tasks YAML file.
|
|
140
|
+
task_id: ID of the task to update.
|
|
141
|
+
description: New description.
|
|
142
|
+
runner: New preferred runner.
|
|
143
|
+
index: New position in the task list.
|
|
144
|
+
status: New status.
|
|
145
|
+
require_progress: If True, raises ValueError if the task has no
|
|
146
|
+
progress.
|
|
147
|
+
parent: New parent task ID.
|
|
148
|
+
parent_tasks_file: New parent tasks file path.
|
|
149
|
+
force: If True, force status transition even if task is in progress.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The updated Task.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: If the task is not found, or if validation fails.
|
|
156
|
+
"""
|
|
157
|
+
with persistence.lock_tasks(tasks_file):
|
|
158
|
+
data = persistence.load_tasks(tasks_file)
|
|
159
|
+
|
|
160
|
+
# Find the task
|
|
161
|
+
task_idx = -1
|
|
162
|
+
target = None
|
|
163
|
+
for i, t in enumerate(data.tasks):
|
|
164
|
+
if t.id.startswith(task_id):
|
|
165
|
+
task_idx = i
|
|
166
|
+
target = t
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
if not target:
|
|
170
|
+
raise models.TaskNotFoundError(f"Task {task_id} not found")
|
|
171
|
+
|
|
172
|
+
if target.status == models.TaskStatus.COMPLETED and description:
|
|
173
|
+
raise ValueError("Cannot edit description of a completed task")
|
|
174
|
+
|
|
175
|
+
if require_progress and not target.progress:
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"Task {target.id} has no recorded progress. "
|
|
178
|
+
"Record at least one progress entry before completing "
|
|
179
|
+
"or failing."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if description is not None:
|
|
183
|
+
target.description = description
|
|
184
|
+
if runner is not None:
|
|
185
|
+
target.runner = runner
|
|
186
|
+
if parent is not None:
|
|
187
|
+
if parent == "":
|
|
188
|
+
target.parent = None
|
|
189
|
+
else:
|
|
190
|
+
target.parent = parent
|
|
191
|
+
if parent_tasks_file is not None:
|
|
192
|
+
if parent_tasks_file == "":
|
|
193
|
+
target.parent_tasks_file = None
|
|
194
|
+
else:
|
|
195
|
+
target.parent_tasks_file = parent_tasks_file
|
|
196
|
+
|
|
197
|
+
if status and status != target.status:
|
|
198
|
+
# If the task is currently in progress, we don't transition to
|
|
199
|
+
# completed/failed immediately. We set requested_status so the
|
|
200
|
+
# orchestrator can run hooks before final completion.
|
|
201
|
+
if (
|
|
202
|
+
not force
|
|
203
|
+
and target.status == models.TaskStatus.IN_PROGRESS
|
|
204
|
+
and status
|
|
205
|
+
in (
|
|
206
|
+
models.TaskStatus.COMPLETED,
|
|
207
|
+
models.TaskStatus.FAILED,
|
|
208
|
+
models.TaskStatus.CANCELLED,
|
|
209
|
+
)
|
|
210
|
+
):
|
|
211
|
+
lifecycle.update_run_time(target)
|
|
212
|
+
target.requested_status = models.TaskStatus(status)
|
|
213
|
+
target.last_started_at = (
|
|
214
|
+
time.time()
|
|
215
|
+
) # Track hook execution time
|
|
216
|
+
else:
|
|
217
|
+
if target.status == models.TaskStatus.IN_PROGRESS:
|
|
218
|
+
lifecycle.update_run_time(target)
|
|
219
|
+
|
|
220
|
+
target.status = models.TaskStatus(status)
|
|
221
|
+
if status in (
|
|
222
|
+
models.TaskStatus.COMPLETED,
|
|
223
|
+
models.TaskStatus.FAILED,
|
|
224
|
+
models.TaskStatus.CANCELLED,
|
|
225
|
+
):
|
|
226
|
+
target.completed_at = time.time()
|
|
227
|
+
target.pid = None
|
|
228
|
+
target.last_heartbeat = None
|
|
229
|
+
target.requested_status = None
|
|
230
|
+
elif status == models.TaskStatus.PENDING:
|
|
231
|
+
target.completed_at = None
|
|
232
|
+
target.attempts = 0
|
|
233
|
+
target.requested_status = None
|
|
234
|
+
elif target.completed_at is not None:
|
|
235
|
+
target.completed_at = None
|
|
236
|
+
|
|
237
|
+
if index is not None:
|
|
238
|
+
task_to_move = data.tasks.pop(task_idx)
|
|
239
|
+
if index == -1:
|
|
240
|
+
data.tasks.append(task_to_move)
|
|
241
|
+
else:
|
|
242
|
+
data.tasks.insert(index, task_to_move)
|
|
243
|
+
|
|
244
|
+
persistence.save_tasks(tasks_file, data)
|
|
245
|
+
return target
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def update_context(tasks_file: pathlib.Path, context: str) -> None:
|
|
249
|
+
"""Updates the project context.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
tasks_file: Path to the tasks YAML file.
|
|
253
|
+
context: The new project context string.
|
|
254
|
+
"""
|
|
255
|
+
with persistence.lock_tasks(tasks_file):
|
|
256
|
+
data = persistence.load_tasks(tasks_file)
|
|
257
|
+
data.context = context
|
|
258
|
+
persistence.save_tasks(tasks_file, data)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from .. import models, persistence
|
|
8
|
+
from . import operations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_add_task_captures_parent_project(tmp_path):
|
|
12
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
13
|
+
parent_file = tmp_path / "parent_tasks.yml"
|
|
14
|
+
|
|
15
|
+
with patch.dict(
|
|
16
|
+
os.environ,
|
|
17
|
+
{
|
|
18
|
+
"LEMMING_PARENT_TASK_ID": "parent123",
|
|
19
|
+
"LEMMING_PARENT_TASKS_FILE": str(parent_file),
|
|
20
|
+
},
|
|
21
|
+
):
|
|
22
|
+
task = operations.add_task(tasks_file, "child task")
|
|
23
|
+
assert task.parent == "parent123"
|
|
24
|
+
assert task.parent_tasks_file == str(parent_file)
|
|
25
|
+
|
|
26
|
+
# Manual override
|
|
27
|
+
task2 = operations.add_task(tasks_file, "another task", parent="override")
|
|
28
|
+
assert task2.parent == "override"
|
|
29
|
+
assert task2.parent_tasks_file is None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_add_task(tmp_path):
|
|
33
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
34
|
+
# Scaffold tasks file
|
|
35
|
+
persistence.save_tasks(tasks_file, models.Roadmap())
|
|
36
|
+
|
|
37
|
+
now = time.time()
|
|
38
|
+
task = operations.add_task(tasks_file, "New task")
|
|
39
|
+
assert task.description == "New task"
|
|
40
|
+
assert task.status == models.TaskStatus.PENDING
|
|
41
|
+
assert task.created_at >= now
|
|
42
|
+
|
|
43
|
+
data = persistence.load_tasks(tasks_file)
|
|
44
|
+
assert len(data.tasks) == 1
|
|
45
|
+
assert data.tasks[0].created_at == task.created_at
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_update_task_description(tmp_path):
|
|
49
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
50
|
+
task = operations.add_task(tasks_file, "Old description")
|
|
51
|
+
task_id = task.id
|
|
52
|
+
|
|
53
|
+
# 1. Successful update
|
|
54
|
+
updated = operations.update_task(
|
|
55
|
+
tasks_file, task_id, description="New description"
|
|
56
|
+
)
|
|
57
|
+
assert updated.description == "New description"
|
|
58
|
+
|
|
59
|
+
# 2. Cannot edit description of a completed task
|
|
60
|
+
operations.update_task(
|
|
61
|
+
tasks_file, task_id, status=models.TaskStatus.COMPLETED
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
with pytest.raises(
|
|
65
|
+
ValueError, match="Cannot edit description of a completed task"
|
|
66
|
+
):
|
|
67
|
+
operations.update_task(
|
|
68
|
+
tasks_file, task_id, description="Trying to change"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_update_task_runner(tmp_path):
|
|
73
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
74
|
+
task = operations.add_task(tasks_file, "Task without runner")
|
|
75
|
+
task_id = task.id
|
|
76
|
+
|
|
77
|
+
assert task.runner is None
|
|
78
|
+
|
|
79
|
+
updated = operations.update_task(
|
|
80
|
+
tasks_file, task_id, runner="custom-runner"
|
|
81
|
+
)
|
|
82
|
+
assert updated.runner == "custom-runner"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_update_task_index(tmp_path):
|
|
86
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
87
|
+
operations.add_task(tasks_file, "Task 0")
|
|
88
|
+
operations.add_task(tasks_file, "Task 1")
|
|
89
|
+
t2 = operations.add_task(tasks_file, "Task 2")
|
|
90
|
+
task_id = t2.id
|
|
91
|
+
|
|
92
|
+
# data.tasks is [Task 0, Task 1, Task 2]
|
|
93
|
+
# Move Task 2 to index 0
|
|
94
|
+
operations.update_task(tasks_file, task_id, index=0)
|
|
95
|
+
data = persistence.load_tasks(tasks_file)
|
|
96
|
+
assert [t.description for t in data.tasks] == ["Task 2", "Task 0", "Task 1"]
|
|
97
|
+
|
|
98
|
+
# Move Task 2 to the end
|
|
99
|
+
operations.update_task(tasks_file, task_id, index=-1)
|
|
100
|
+
data = persistence.load_tasks(tasks_file)
|
|
101
|
+
assert [t.description for t in data.tasks] == ["Task 0", "Task 1", "Task 2"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_update_task_status_lifecycle(tmp_path):
|
|
105
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
106
|
+
task = operations.add_task(tasks_file, "Status lifecycle")
|
|
107
|
+
task_id = task.id
|
|
108
|
+
|
|
109
|
+
# 1. PENDING -> COMPLETED
|
|
110
|
+
updated = operations.update_task(
|
|
111
|
+
tasks_file, task_id, status=models.TaskStatus.COMPLETED
|
|
112
|
+
)
|
|
113
|
+
assert updated.status == models.TaskStatus.COMPLETED
|
|
114
|
+
assert updated.completed_at is not None
|
|
115
|
+
|
|
116
|
+
# 2. COMPLETED -> PENDING (resets attempts and completed_at)
|
|
117
|
+
with persistence.lock_tasks(tasks_file):
|
|
118
|
+
data = persistence.load_tasks(tasks_file)
|
|
119
|
+
data.tasks[0].attempts = 5
|
|
120
|
+
persistence.save_tasks(tasks_file, data)
|
|
121
|
+
|
|
122
|
+
updated = operations.update_task(
|
|
123
|
+
tasks_file, task_id, status=models.TaskStatus.PENDING
|
|
124
|
+
)
|
|
125
|
+
assert updated.status == models.TaskStatus.PENDING
|
|
126
|
+
assert updated.completed_at is None
|
|
127
|
+
assert updated.attempts == 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_update_task_parent_fields(tmp_path):
|
|
131
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
132
|
+
task = operations.add_task(tasks_file, "Update parent fields")
|
|
133
|
+
task_id = task.id
|
|
134
|
+
|
|
135
|
+
# 1. Update with values
|
|
136
|
+
updated = operations.update_task(
|
|
137
|
+
tasks_file,
|
|
138
|
+
task_id,
|
|
139
|
+
parent="parent123",
|
|
140
|
+
parent_tasks_file="parent_tasks.yml",
|
|
141
|
+
)
|
|
142
|
+
assert updated.parent == "parent123"
|
|
143
|
+
assert updated.parent_tasks_file == "parent_tasks.yml"
|
|
144
|
+
|
|
145
|
+
# 2. Clear values with empty strings
|
|
146
|
+
cleared = operations.update_task(
|
|
147
|
+
tasks_file,
|
|
148
|
+
task_id,
|
|
149
|
+
parent="",
|
|
150
|
+
parent_tasks_file="",
|
|
151
|
+
)
|
|
152
|
+
assert cleared.parent is None
|
|
153
|
+
assert cleared.parent_tasks_file is None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_delete_tasks(tmp_path):
|
|
157
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
158
|
+
operations.add_task(tasks_file, "Task 1")
|
|
159
|
+
t2 = operations.add_task(tasks_file, "Task 2")
|
|
160
|
+
|
|
161
|
+
count = operations.delete_tasks(tasks_file, task_id=t2.id)
|
|
162
|
+
assert count == 1
|
|
163
|
+
data = persistence.load_tasks(tasks_file)
|
|
164
|
+
assert len(data.tasks) == 1
|
|
165
|
+
assert data.tasks[0].description == "Task 1"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_update_context(tmp_path):
|
|
169
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
170
|
+
operations.update_context(tasks_file, "new context")
|
|
171
|
+
data = persistence.load_tasks(tasks_file)
|
|
172
|
+
assert data.context == "new context"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Recording of task progress entries."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
|
|
5
|
+
from .. import models, persistence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def add_progress(
|
|
9
|
+
tasks_file: pathlib.Path, task_id: str, text: str
|
|
10
|
+
) -> models.Task:
|
|
11
|
+
"""Adds a progress entry to a task.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
tasks_file: Path to the tasks YAML file.
|
|
15
|
+
task_id: ID of the task to add progress to.
|
|
16
|
+
text: The progress text to add.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
The updated Task.
|
|
20
|
+
"""
|
|
21
|
+
with persistence.lock_tasks(tasks_file):
|
|
22
|
+
data = persistence.load_tasks(tasks_file)
|
|
23
|
+
target = next((t for t in data.tasks if t.id.startswith(task_id)), None)
|
|
24
|
+
if not target:
|
|
25
|
+
raise ValueError(f"Task {task_id} not found")
|
|
26
|
+
|
|
27
|
+
target.progress.append(text)
|
|
28
|
+
persistence.save_tasks(tasks_file, data)
|
|
29
|
+
return target
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from lemming import models, persistence
|
|
2
|
+
from lemming.tasks import progress
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_add_progress(tmp_path):
|
|
6
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
7
|
+
data = models.Roadmap(
|
|
8
|
+
tasks=[
|
|
9
|
+
models.Task(
|
|
10
|
+
id="123",
|
|
11
|
+
description="Task 1",
|
|
12
|
+
)
|
|
13
|
+
]
|
|
14
|
+
)
|
|
15
|
+
# create file to test load/save logic
|
|
16
|
+
persistence.save_tasks(tasks_file, data)
|
|
17
|
+
|
|
18
|
+
target = progress.add_progress(tasks_file, "123", "Found bug in module X")
|
|
19
|
+
|
|
20
|
+
assert target.id == "123"
|
|
21
|
+
assert len(target.progress) == 1
|
|
22
|
+
assert target.progress[0] == "Found bug in module X"
|
lemming/tasks/queries.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Read-only queries over project tasks and orchestrator state."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from .. import models, paths, persistence
|
|
7
|
+
from . import lifecycle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_project_data(tasks_file: pathlib.Path) -> models.ProjectData:
|
|
11
|
+
"""Consolidated logic to get project state (tasks, context, loop status).
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
tasks_file: Path to the tasks YAML file.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
A ProjectData containing the enriched project state.
|
|
18
|
+
"""
|
|
19
|
+
with persistence.read_lock_tasks(tasks_file):
|
|
20
|
+
data = persistence.load_tasks(tasks_file)
|
|
21
|
+
now = time.time()
|
|
22
|
+
|
|
23
|
+
# Deduplicate tasks by ID and enrich metadata.
|
|
24
|
+
seen_ids = set()
|
|
25
|
+
unique_tasks = []
|
|
26
|
+
loop_running = lifecycle.is_loop_running(tasks_file)
|
|
27
|
+
|
|
28
|
+
for i, t in enumerate(data.tasks):
|
|
29
|
+
if t.id in seen_ids:
|
|
30
|
+
continue
|
|
31
|
+
seen_ids.add(t.id)
|
|
32
|
+
unique_tasks.append(t)
|
|
33
|
+
|
|
34
|
+
t.index = i
|
|
35
|
+
t.has_runner_log = paths.get_log_file(tasks_file, t.id).exists()
|
|
36
|
+
|
|
37
|
+
# Detect active loop from task heartbeats if not already known.
|
|
38
|
+
if not loop_running and lifecycle.is_task_active(t, now):
|
|
39
|
+
loop_running = True
|
|
40
|
+
|
|
41
|
+
# Unified sort: uncompleted (pending, in_progress) first, then completed
|
|
42
|
+
# (completed, failed). Within each group:
|
|
43
|
+
# - Uncompleted: in_progress tasks first, then by index (YAML order),
|
|
44
|
+
# then by created_at.
|
|
45
|
+
# - Completed: newer tasks appear first (reverse chronological).
|
|
46
|
+
def sort_key(t):
|
|
47
|
+
is_done = t.status in (
|
|
48
|
+
models.TaskStatus.COMPLETED,
|
|
49
|
+
models.TaskStatus.FAILED,
|
|
50
|
+
models.TaskStatus.CANCELLED,
|
|
51
|
+
)
|
|
52
|
+
is_in_progress = t.status == models.TaskStatus.IN_PROGRESS
|
|
53
|
+
if not is_done:
|
|
54
|
+
# For pending/in_progress, prioritize in_progress and then YAML
|
|
55
|
+
# order (index)
|
|
56
|
+
return (
|
|
57
|
+
0,
|
|
58
|
+
0 if is_in_progress else 1,
|
|
59
|
+
(t.index if t.index is not None else 0),
|
|
60
|
+
(t.created_at or 0),
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
# For completed/failed, we want newest first
|
|
64
|
+
return (
|
|
65
|
+
1,
|
|
66
|
+
-(t.completed_at or t.created_at or 0),
|
|
67
|
+
-(t.index if t.index is not None else 0),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
unique_tasks.sort(key=sort_key)
|
|
71
|
+
|
|
72
|
+
return models.ProjectData(
|
|
73
|
+
context=data.context,
|
|
74
|
+
tasks=unique_tasks,
|
|
75
|
+
config=data.config,
|
|
76
|
+
cwd=str(paths.get_working_dir(tasks_file)),
|
|
77
|
+
loop_running=loop_running,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_pending_task(data: models.Roadmap) -> models.Task | None:
|
|
82
|
+
"""Finds the next task to be executed.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
data: The Roadmap to search.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The next Task to execute, or None if no tasks are pending or a task
|
|
89
|
+
is already in progress.
|
|
90
|
+
"""
|
|
91
|
+
now = time.time()
|
|
92
|
+
|
|
93
|
+
# 1. Check if ANY task is currently active (running or running hooks).
|
|
94
|
+
# If so, we must not start any other task (unless it's finalizing).
|
|
95
|
+
for task in data.tasks:
|
|
96
|
+
if lifecycle.is_task_active(task, now):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
# If it's NOT active, but it has a requested_status, it means it's a
|
|
100
|
+
# crashed finalizing task. We MUST pick it up immediately.
|
|
101
|
+
if task.requested_status:
|
|
102
|
+
return task
|
|
103
|
+
|
|
104
|
+
# 2. Sort uncompleted tasks (pending or stale in_progress) to pick the
|
|
105
|
+
# oldest one (FIFO). Note: we exclude tasks that are currently finalizing
|
|
106
|
+
# (have requested_status).
|
|
107
|
+
uncompleted = []
|
|
108
|
+
for i, task in enumerate(data.tasks):
|
|
109
|
+
if (
|
|
110
|
+
task.status
|
|
111
|
+
in (models.TaskStatus.PENDING, models.TaskStatus.IN_PROGRESS)
|
|
112
|
+
and not task.requested_status
|
|
113
|
+
):
|
|
114
|
+
# We need to preserve the original index for tie-breaking
|
|
115
|
+
task.index = i
|
|
116
|
+
uncompleted.append(task)
|
|
117
|
+
|
|
118
|
+
if not uncompleted:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def sort_key(t):
|
|
122
|
+
return (
|
|
123
|
+
(t.index if t.index is not None else 0),
|
|
124
|
+
(t.created_at or 0),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
uncompleted.sort(key=sort_key)
|
|
128
|
+
return uncompleted[0]
|