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/prompts_test.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
from lemming import models, paths, prompts, tasks
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_load_prompt():
|
|
5
|
+
prompt = prompts.load_prompt("taskrunner")
|
|
6
|
+
assert "roadmap" in prompt
|
|
7
|
+
assert "description" in prompt
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_prepare_prompt(tmp_path):
|
|
11
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
12
|
+
data = tasks.Roadmap(
|
|
13
|
+
context="My context",
|
|
14
|
+
tasks=[
|
|
15
|
+
tasks.Task(
|
|
16
|
+
id="1",
|
|
17
|
+
description="T1",
|
|
18
|
+
status=tasks.TaskStatus.COMPLETED,
|
|
19
|
+
progress=["O1"],
|
|
20
|
+
),
|
|
21
|
+
tasks.Task(
|
|
22
|
+
id="2", description="T2", status=tasks.TaskStatus.PENDING
|
|
23
|
+
),
|
|
24
|
+
],
|
|
25
|
+
)
|
|
26
|
+
task = data.tasks[1]
|
|
27
|
+
prompt = prompts.prepare_prompt(data, task, tasks_file)
|
|
28
|
+
assert "My context" in prompt
|
|
29
|
+
assert "T2" in prompt
|
|
30
|
+
assert "T1" in prompt
|
|
31
|
+
assert "O1" in prompt
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_prepare_prompt_with_parent_context(tmp_path):
|
|
35
|
+
|
|
36
|
+
root_tasks_file = tmp_path / "root_tasks.yml"
|
|
37
|
+
sub_tasks_file = tmp_path / "sub_tasks.yml"
|
|
38
|
+
|
|
39
|
+
# Setup parent task
|
|
40
|
+
parent_task = tasks.Task(
|
|
41
|
+
id="parent123",
|
|
42
|
+
description="Parent Task Description",
|
|
43
|
+
progress=["Parent Outcome 1"],
|
|
44
|
+
)
|
|
45
|
+
root_data = tasks.Roadmap(tasks=[parent_task])
|
|
46
|
+
tasks.save_tasks(root_tasks_file, root_data)
|
|
47
|
+
|
|
48
|
+
# Setup child task referencing parent
|
|
49
|
+
child_task = tasks.Task(
|
|
50
|
+
id="child456",
|
|
51
|
+
description="Child Task",
|
|
52
|
+
parent="parent123",
|
|
53
|
+
parent_tasks_file=str(root_tasks_file),
|
|
54
|
+
)
|
|
55
|
+
sub_data = tasks.Roadmap(tasks=[child_task])
|
|
56
|
+
|
|
57
|
+
prompt = prompts.prepare_prompt(sub_data, child_task, sub_tasks_file)
|
|
58
|
+
|
|
59
|
+
assert "Parent Task Context" in prompt
|
|
60
|
+
assert "Parent Task Description" in prompt
|
|
61
|
+
assert "Parent Outcome 1" in prompt
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_load_prompt_discovery(tmp_path, monkeypatch):
|
|
65
|
+
"""Tests the discovery of hook prompts across different layers."""
|
|
66
|
+
# Setup global hooks dir
|
|
67
|
+
lemming_home = tmp_path / "lemming_home"
|
|
68
|
+
global_hooks_dir = lemming_home / "hooks"
|
|
69
|
+
global_hooks_dir.mkdir(parents=True)
|
|
70
|
+
monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
|
|
71
|
+
|
|
72
|
+
# Setup project dir
|
|
73
|
+
project_dir = tmp_path / "project"
|
|
74
|
+
project_dir.mkdir()
|
|
75
|
+
local_hooks_dir = project_dir / ".lemming" / "hooks"
|
|
76
|
+
local_hooks_dir.mkdir(parents=True)
|
|
77
|
+
tasks_file = project_dir / "tasks.yml"
|
|
78
|
+
tasks_file.write_text("tasks: []")
|
|
79
|
+
|
|
80
|
+
# 1. Test local hook
|
|
81
|
+
(local_hooks_dir / "myhook.md").write_text("local content")
|
|
82
|
+
assert prompts.load_prompt("myhook", tasks_file) == "local content"
|
|
83
|
+
|
|
84
|
+
# 2. Test global hook (when local doesn't exist)
|
|
85
|
+
(global_hooks_dir / "globalhook.md").write_text("global content")
|
|
86
|
+
assert prompts.load_prompt("globalhook", tasks_file) == "global content"
|
|
87
|
+
|
|
88
|
+
# 3. Test precedence: local > global
|
|
89
|
+
(global_hooks_dir / "myhook.md").write_text("global content")
|
|
90
|
+
assert prompts.load_prompt("myhook", tasks_file) == "local content"
|
|
91
|
+
|
|
92
|
+
# 4. Test built-in (fallback)
|
|
93
|
+
assert "You are a roadmap orchestrator" in prompts.load_prompt(
|
|
94
|
+
"roadmap", tasks_file
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_prepare_hook_prompt_substitution(tmp_path, monkeypatch):
|
|
99
|
+
# Setup paths to avoid polluting real global/home
|
|
100
|
+
lemming_home = tmp_path / "lemming_home"
|
|
101
|
+
monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
|
|
102
|
+
|
|
103
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
104
|
+
data = tasks.Roadmap(
|
|
105
|
+
context="Project Context",
|
|
106
|
+
tasks=[
|
|
107
|
+
tasks.Task(
|
|
108
|
+
id="task1",
|
|
109
|
+
description="Task 1",
|
|
110
|
+
status=tasks.TaskStatus.COMPLETED,
|
|
111
|
+
progress=["Done"],
|
|
112
|
+
),
|
|
113
|
+
tasks.Task(
|
|
114
|
+
id="task2",
|
|
115
|
+
description="Task 2",
|
|
116
|
+
status=tasks.TaskStatus.IN_PROGRESS,
|
|
117
|
+
),
|
|
118
|
+
],
|
|
119
|
+
)
|
|
120
|
+
finished_task = data.tasks[0]
|
|
121
|
+
|
|
122
|
+
# Create a mock hook prompt with all placeholders
|
|
123
|
+
local_hooks_dir = tmp_path / ".lemming" / "hooks"
|
|
124
|
+
local_hooks_dir.mkdir(parents=True)
|
|
125
|
+
hook_prompt_file = local_hooks_dir / "test-hook.md"
|
|
126
|
+
hook_prompt_file.write_text("""
|
|
127
|
+
Roadmap: {{roadmap}}
|
|
128
|
+
Finished Task: {{finished_task}}
|
|
129
|
+
ID: {{finished_task_id}}
|
|
130
|
+
File Name: {{tasks_file_name}}
|
|
131
|
+
File Path: {{tasks_file_path}}
|
|
132
|
+
""")
|
|
133
|
+
|
|
134
|
+
# Mock log file
|
|
135
|
+
log_file = paths.get_log_file(tasks_file, finished_task.id)
|
|
136
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
log_file.write_text("line 1\nline 2\nline 3")
|
|
138
|
+
|
|
139
|
+
prompt = prompts.prepare_hook_prompt(
|
|
140
|
+
"test-hook", data, finished_task, tasks_file
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Verify roadmap substitution
|
|
144
|
+
assert "Roadmap: ## Project Context" in prompt
|
|
145
|
+
assert "**[COMPLETED] (task1) Task 1**" in prompt
|
|
146
|
+
assert " - Done" in prompt
|
|
147
|
+
assert "- [IN PROGRESS] (task2) Task 2" in prompt
|
|
148
|
+
|
|
149
|
+
# Verify finished task substitution
|
|
150
|
+
assert "Finished Task: Task ID: task1" in prompt
|
|
151
|
+
assert "Description: Task 1" in prompt
|
|
152
|
+
assert "Result: completed" in prompt
|
|
153
|
+
assert "Progress recorded during this attempt:\n- Done" in prompt
|
|
154
|
+
|
|
155
|
+
# Verify log inclusion
|
|
156
|
+
assert "Execution log of THIS task" in prompt
|
|
157
|
+
assert "line 1\nline 2\nline 3" in prompt
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_prepare_prompt_local_override(tmp_path):
|
|
161
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
162
|
+
project_dir = tmp_path
|
|
163
|
+
local_hooks_dir = project_dir / ".lemming" / "hooks"
|
|
164
|
+
local_hooks_dir.mkdir(parents=True)
|
|
165
|
+
(local_hooks_dir / "taskrunner.md").write_text(
|
|
166
|
+
"LOCAL OVERRIDE {{description}}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
data = tasks.Roadmap(
|
|
170
|
+
tasks=[
|
|
171
|
+
tasks.Task(id="1", description="My Task"),
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
task = data.tasks[0]
|
|
175
|
+
prompt = prompts.prepare_prompt(data, task, tasks_file)
|
|
176
|
+
assert "LOCAL OVERRIDE My Task" in prompt
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_prepare_hook_prompt_filters_command_noise(tmp_path, monkeypatch):
|
|
180
|
+
# Setup paths to avoid polluting real global/home
|
|
181
|
+
lemming_home = tmp_path / "lemming_home"
|
|
182
|
+
monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
|
|
183
|
+
|
|
184
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
185
|
+
data = tasks.Roadmap(
|
|
186
|
+
context="Project Context",
|
|
187
|
+
tasks=[
|
|
188
|
+
tasks.Task(
|
|
189
|
+
id="task1",
|
|
190
|
+
description="Task 1",
|
|
191
|
+
status=tasks.TaskStatus.COMPLETED,
|
|
192
|
+
),
|
|
193
|
+
],
|
|
194
|
+
)
|
|
195
|
+
finished_task = data.tasks[0]
|
|
196
|
+
|
|
197
|
+
# Create a mock hook prompt with all placeholders
|
|
198
|
+
local_hooks_dir = tmp_path / ".lemming" / "hooks"
|
|
199
|
+
local_hooks_dir.mkdir(parents=True)
|
|
200
|
+
hook_prompt_file = local_hooks_dir / "test-hook.md"
|
|
201
|
+
hook_prompt_file.write_text("Log: {{finished_task}}")
|
|
202
|
+
|
|
203
|
+
# Mock log file with a long Command: line
|
|
204
|
+
log_file = paths.get_log_file(tasks_file, finished_task.id)
|
|
205
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
log_file.write_text(
|
|
207
|
+
"Command: agy --prompt \"HUGE PROMPT WITH 'QUOTES'\" ...\n"
|
|
208
|
+
"Real output from AI\n"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
prompt = prompts.prepare_hook_prompt(
|
|
212
|
+
"test-hook", data, finished_task, tasks_file
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Verify Command: line is filtered out
|
|
216
|
+
assert "HUGE PROMPT" not in prompt
|
|
217
|
+
assert "Real output from AI" in prompt
|
|
218
|
+
assert "Execution log of THIS task" in prompt
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_ensure_hooks_symlinked(tmp_path, monkeypatch):
|
|
222
|
+
# Setup mock lemming home
|
|
223
|
+
lemming_home = tmp_path / "lemming_home"
|
|
224
|
+
monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
|
|
225
|
+
|
|
226
|
+
global_hooks_dir = paths.get_global_hooks_dir()
|
|
227
|
+
assert not global_hooks_dir.exists()
|
|
228
|
+
|
|
229
|
+
# Run ensure_hooks_symlinked
|
|
230
|
+
prompts.ensure_hooks_symlinked()
|
|
231
|
+
|
|
232
|
+
assert global_hooks_dir.exists()
|
|
233
|
+
assert (global_hooks_dir / "roadmap.md").is_symlink()
|
|
234
|
+
assert (global_hooks_dir / "readability.md").is_symlink()
|
|
235
|
+
|
|
236
|
+
# Check if we can load it
|
|
237
|
+
content = prompts.load_prompt("roadmap")
|
|
238
|
+
assert "roadmap orchestrator" in content.lower()
|
|
239
|
+
|
|
240
|
+
content = prompts.load_prompt("readability")
|
|
241
|
+
assert "google style guide" in content.lower()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_list_hooks_includes_all(tmp_path, monkeypatch):
|
|
245
|
+
# Setup mock lemming home
|
|
246
|
+
lemming_home = tmp_path / "lemming_home"
|
|
247
|
+
monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
|
|
248
|
+
|
|
249
|
+
# Project hooks
|
|
250
|
+
project_dir = tmp_path / "project"
|
|
251
|
+
project_dir.mkdir()
|
|
252
|
+
local_hooks_dir = project_dir / ".lemming" / "hooks"
|
|
253
|
+
local_hooks_dir.mkdir(parents=True)
|
|
254
|
+
(local_hooks_dir / "custom_hook.md").write_text("custom", encoding="utf-8")
|
|
255
|
+
|
|
256
|
+
tasks_file = project_dir / "tasks.yml"
|
|
257
|
+
tasks_file.touch()
|
|
258
|
+
|
|
259
|
+
# Run list_hooks
|
|
260
|
+
hooks = prompts.list_hooks(tasks_file)
|
|
261
|
+
|
|
262
|
+
assert "roadmap" in hooks
|
|
263
|
+
assert "readability" in hooks
|
|
264
|
+
assert "custom_hook" in hooks
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_hook_override_precedence(tmp_path, monkeypatch):
|
|
268
|
+
# Setup mock lemming home
|
|
269
|
+
lemming_home = tmp_path / "lemming_home"
|
|
270
|
+
monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
|
|
271
|
+
|
|
272
|
+
# Global override
|
|
273
|
+
global_hooks_dir = paths.get_global_hooks_dir()
|
|
274
|
+
global_hooks_dir.mkdir(parents=True)
|
|
275
|
+
(global_hooks_dir / "roadmap.md").write_text(
|
|
276
|
+
"global roadmap", encoding="utf-8"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Project override
|
|
280
|
+
project_dir = tmp_path / "project"
|
|
281
|
+
project_dir.mkdir()
|
|
282
|
+
local_hooks_dir = project_dir / ".lemming" / "hooks"
|
|
283
|
+
local_hooks_dir.mkdir(parents=True)
|
|
284
|
+
(local_hooks_dir / "roadmap.md").write_text(
|
|
285
|
+
"project roadmap", encoding="utf-8"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
tasks_file = project_dir / "tasks.yml"
|
|
289
|
+
tasks_file.touch()
|
|
290
|
+
|
|
291
|
+
# Check precedence
|
|
292
|
+
content = prompts.load_prompt("roadmap", tasks_file)
|
|
293
|
+
assert content == "project roadmap"
|
|
294
|
+
|
|
295
|
+
# Remove project override
|
|
296
|
+
(local_hooks_dir / "roadmap.md").unlink()
|
|
297
|
+
content = prompts.load_prompt("roadmap", tasks_file)
|
|
298
|
+
assert content == "global roadmap"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_prepare_prompt_time_limit_section(tmp_path):
|
|
302
|
+
"""Verifies the time limit section is injected when time_limit > 0."""
|
|
303
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
304
|
+
data = tasks.Roadmap(
|
|
305
|
+
tasks=[tasks.Task(id="1", description="T1")],
|
|
306
|
+
)
|
|
307
|
+
task = data.tasks[0]
|
|
308
|
+
|
|
309
|
+
# With time limit (60 minutes)
|
|
310
|
+
prompt = prompts.prepare_prompt(data, task, tasks_file, time_limit=60)
|
|
311
|
+
assert "## Time Limit" in prompt
|
|
312
|
+
assert "60 minutes" in prompt
|
|
313
|
+
assert "Record progress early" in prompt
|
|
314
|
+
assert "subagents" in prompt
|
|
315
|
+
|
|
316
|
+
# Without time limit
|
|
317
|
+
prompt_no_limit = prompts.prepare_prompt(
|
|
318
|
+
data, task, tasks_file, time_limit=0
|
|
319
|
+
)
|
|
320
|
+
assert "## Time Limit" not in prompt_no_limit
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_prepare_prompt_time_limit_custom(tmp_path):
|
|
324
|
+
"""Verifies the time limit section uses the correct minute value."""
|
|
325
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
326
|
+
data = tasks.Roadmap(
|
|
327
|
+
tasks=[tasks.Task(id="1", description="T1")],
|
|
328
|
+
)
|
|
329
|
+
task = data.tasks[0]
|
|
330
|
+
|
|
331
|
+
prompt = prompts.prepare_prompt(data, task, tasks_file, time_limit=30)
|
|
332
|
+
assert "30 minutes" in prompt
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_prepare_hook_prompt_shows_failed_for_exhausted_task(
|
|
336
|
+
tmp_path, monkeypatch
|
|
337
|
+
):
|
|
338
|
+
"""A task with requested_status=FAILED (during hook execution) should show
|
|
339
|
+
Result: failed and [FAILED] in the roadmap, not in_progress."""
|
|
340
|
+
lemming_home = tmp_path / "lemming_home"
|
|
341
|
+
monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
|
|
342
|
+
|
|
343
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
344
|
+
# Simulate the state during hook execution after retry exhaustion:
|
|
345
|
+
# status=IN_PROGRESS, requested_status=FAILED
|
|
346
|
+
failed_task = tasks.Task(
|
|
347
|
+
id="task1",
|
|
348
|
+
description="Flaky task",
|
|
349
|
+
status=tasks.TaskStatus.IN_PROGRESS,
|
|
350
|
+
requested_status=tasks.TaskStatus.FAILED,
|
|
351
|
+
attempts=3,
|
|
352
|
+
progress=["Task killed: time limit of 60 minutes reached."],
|
|
353
|
+
)
|
|
354
|
+
data = tasks.Roadmap(
|
|
355
|
+
context="Test",
|
|
356
|
+
tasks=[
|
|
357
|
+
failed_task,
|
|
358
|
+
tasks.Task(id="task2", description="Next task"),
|
|
359
|
+
],
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
local_hooks_dir = tmp_path / ".lemming" / "hooks"
|
|
363
|
+
local_hooks_dir.mkdir(parents=True)
|
|
364
|
+
(local_hooks_dir / "test-hook.md").write_text(
|
|
365
|
+
"Roadmap: {{roadmap}}\nFinished: {{finished_task}}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
prompt = prompts.prepare_hook_prompt(
|
|
369
|
+
"test-hook", data, failed_task, tasks_file
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# The finished task section must show "failed", not "in_progress"
|
|
373
|
+
assert "Result: failed" in prompt
|
|
374
|
+
# The roadmap overview must show [FAILED], not [IN PROGRESS]
|
|
375
|
+
assert "[FAILED - 3/3 attempt(s)]" in prompt
|
|
376
|
+
assert "[PENDING]" in prompt
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_list_hooks_roadmap_is_last(tmp_path):
|
|
380
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
381
|
+
tasks_file.touch()
|
|
382
|
+
|
|
383
|
+
# Create project hooks that would alphabetically come before and after
|
|
384
|
+
# 'roadmap'
|
|
385
|
+
local_hooks_dir = tmp_path / ".lemming" / "hooks"
|
|
386
|
+
local_hooks_dir.mkdir(parents=True)
|
|
387
|
+
(local_hooks_dir / "z_hook.md").write_text("z", encoding="utf-8")
|
|
388
|
+
(local_hooks_dir / "a_hook.md").write_text("a", encoding="utf-8")
|
|
389
|
+
|
|
390
|
+
hooks = prompts.list_hooks(tasks_file)
|
|
391
|
+
|
|
392
|
+
assert "roadmap" in hooks
|
|
393
|
+
assert "z_hook" in hooks
|
|
394
|
+
assert "a_hook" in hooks
|
|
395
|
+
# Even though z_hook is alphabetically last, roadmap should be moved to
|
|
396
|
+
# the end
|
|
397
|
+
assert hooks[-1] == "roadmap"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_format_roadmap_with_finalizing_task():
|
|
401
|
+
data = models.Roadmap(
|
|
402
|
+
tasks=[
|
|
403
|
+
models.Task(
|
|
404
|
+
id="1",
|
|
405
|
+
description="Done",
|
|
406
|
+
status=models.TaskStatus.COMPLETED,
|
|
407
|
+
progress=["a"],
|
|
408
|
+
),
|
|
409
|
+
models.Task(
|
|
410
|
+
id="2",
|
|
411
|
+
description="Finalizing",
|
|
412
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
413
|
+
requested_status=models.TaskStatus.COMPLETED,
|
|
414
|
+
progress=["b"],
|
|
415
|
+
),
|
|
416
|
+
]
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
output = prompts._format_roadmap(data)
|
|
420
|
+
assert "[COMPLETED] (2)" in output
|
|
421
|
+
assert "- b" in output
|
lemming/providers.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Tunnel providers that expose a local port through a public URL."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TunnelProvider(abc.ABC):
|
|
11
|
+
"""Abstract interface for a tunnel exposing a local port publicly."""
|
|
12
|
+
|
|
13
|
+
@abc.abstractmethod
|
|
14
|
+
def start(self, local_port: int) -> str:
|
|
15
|
+
"""Starts the tunnel and returns the public URL.
|
|
16
|
+
|
|
17
|
+
Raises an exception on failure.
|
|
18
|
+
"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@abc.abstractmethod
|
|
22
|
+
def stop(self):
|
|
23
|
+
"""Tears down the tunnel."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CloudflareProvider(TunnelProvider):
|
|
28
|
+
"""Tunnel provider backed by Cloudflare quick tunnels (cloudflared)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
"""Initializes the provider with no running tunnel process."""
|
|
32
|
+
self.process: subprocess.Popen | None = None
|
|
33
|
+
|
|
34
|
+
def start(self, local_port: int) -> str:
|
|
35
|
+
"""Starts a Cloudflare quick tunnel to the given local port.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
local_port: Local port to expose through the tunnel.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The public tunnel URL.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
RuntimeError: If cloudflared is missing or no URL is obtained.
|
|
45
|
+
"""
|
|
46
|
+
if (
|
|
47
|
+
subprocess.run(
|
|
48
|
+
["which", "cloudflared"], capture_output=True, check=False
|
|
49
|
+
).returncode
|
|
50
|
+
!= 0
|
|
51
|
+
):
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
"cloudflared not found in PATH. Please install it via "
|
|
54
|
+
"'brew install cloudflare/cloudflare/cloudflared' "
|
|
55
|
+
"or visit https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
cmd = [
|
|
59
|
+
"cloudflared",
|
|
60
|
+
"tunnel",
|
|
61
|
+
"--url",
|
|
62
|
+
f"http://127.0.0.1:{local_port}",
|
|
63
|
+
]
|
|
64
|
+
self.process = subprocess.Popen(
|
|
65
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
url_pattern = re.compile(r"(https://[a-zA-Z0-9-]+\.trycloudflare\.com)")
|
|
69
|
+
|
|
70
|
+
start_time = time.time()
|
|
71
|
+
while time.time() - start_time < 15:
|
|
72
|
+
if self.process.stdout is None:
|
|
73
|
+
break
|
|
74
|
+
line = self.process.stdout.readline()
|
|
75
|
+
if not line:
|
|
76
|
+
# Process might have died
|
|
77
|
+
if self.process.poll() is not None:
|
|
78
|
+
break
|
|
79
|
+
continue
|
|
80
|
+
match = url_pattern.search(line)
|
|
81
|
+
if (
|
|
82
|
+
match
|
|
83
|
+
and "api.trycloudflare.com" not in match.group(1)
|
|
84
|
+
and "update.trycloudflare.com" not in match.group(1)
|
|
85
|
+
):
|
|
86
|
+
return match.group(1)
|
|
87
|
+
|
|
88
|
+
self.stop()
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
"Failed to obtain Cloudflare tunnel URL within 15 seconds."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def stop(self):
|
|
94
|
+
"""Terminates the cloudflared process, if one is running."""
|
|
95
|
+
if self.process:
|
|
96
|
+
self.process.terminate()
|
|
97
|
+
try:
|
|
98
|
+
self.process.wait(timeout=5)
|
|
99
|
+
except subprocess.TimeoutExpired:
|
|
100
|
+
self.process.kill()
|
|
101
|
+
if self.process.stdout:
|
|
102
|
+
self.process.stdout.close()
|
|
103
|
+
self.process = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TailscaleProvider(TunnelProvider):
|
|
107
|
+
"""Tunnel provider backed by Tailscale serve and funnel."""
|
|
108
|
+
|
|
109
|
+
def __init__(self):
|
|
110
|
+
"""Initializes the provider with no running tunnel process."""
|
|
111
|
+
self.process: subprocess.Popen | None = None
|
|
112
|
+
|
|
113
|
+
def start(self, local_port: int) -> str:
|
|
114
|
+
"""Exposes the given local port via Tailscale serve and funnel.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
local_port: Local port to expose through the tunnel.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The public URL derived from the node's Tailscale domain.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
RuntimeError: If tailscale is missing or setup fails.
|
|
124
|
+
"""
|
|
125
|
+
if (
|
|
126
|
+
subprocess.run(
|
|
127
|
+
["which", "tailscale"], capture_output=True, check=False
|
|
128
|
+
).returncode
|
|
129
|
+
!= 0
|
|
130
|
+
):
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
"tailscale not found in PATH. Please install it from https://tailscale.com/download"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
cmd = [
|
|
136
|
+
"tailscale",
|
|
137
|
+
"serve",
|
|
138
|
+
"https",
|
|
139
|
+
"/",
|
|
140
|
+
f"http://127.0.0.1:{local_port}",
|
|
141
|
+
]
|
|
142
|
+
try:
|
|
143
|
+
subprocess.run(cmd, capture_output=True, check=True)
|
|
144
|
+
# funnel is enabled separately: tailscale funnel 8999
|
|
145
|
+
subprocess.run(
|
|
146
|
+
["tailscale", "funnel", str(local_port), "on"],
|
|
147
|
+
capture_output=True,
|
|
148
|
+
check=True,
|
|
149
|
+
)
|
|
150
|
+
except subprocess.CalledProcessError as e:
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
"Failed to start tailscale serve/funnel: "
|
|
153
|
+
f"{e.stderr.decode() if e.stderr else str(e)}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
status = subprocess.run(
|
|
157
|
+
["tailscale", "status", "--json"],
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
check=False,
|
|
161
|
+
)
|
|
162
|
+
if status.returncode != 0:
|
|
163
|
+
self.stop()
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
"Failed to run 'tailscale status'. Is tailscaled running?"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
data = json.loads(status.stdout)
|
|
170
|
+
domain = data.get("Self", {}).get("DNSName", "").strip(".")
|
|
171
|
+
if not domain:
|
|
172
|
+
raise ValueError("No DNSName found in tailscale status.")
|
|
173
|
+
return f"https://{domain}:{local_port}"
|
|
174
|
+
except Exception as e:
|
|
175
|
+
self.stop()
|
|
176
|
+
raise RuntimeError(f"Failed to determine tailscale domain: {e}")
|
|
177
|
+
|
|
178
|
+
def stop(self):
|
|
179
|
+
"""Turns off Tailscale funnel and serve for this node."""
|
|
180
|
+
# We don't have a long-running process to kill, but we can turn off
|
|
181
|
+
# funnel and serve. It's cleaner to turn them off.
|
|
182
|
+
subprocess.run(
|
|
183
|
+
["tailscale", "funnel", "off"], capture_output=True, check=False
|
|
184
|
+
)
|
|
185
|
+
subprocess.run(
|
|
186
|
+
["tailscale", "serve", "reset"], capture_output=True, check=False
|
|
187
|
+
)
|
|
188
|
+
if self.process:
|
|
189
|
+
self.process.terminate()
|
|
190
|
+
self.process = None
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import unittest.mock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from lemming import providers
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@unittest.mock.patch("subprocess.run")
|
|
9
|
+
def test_cloudflare_provider_binary_missing(mock_run):
|
|
10
|
+
mock_run.return_value.returncode = 1
|
|
11
|
+
provider = providers.CloudflareProvider()
|
|
12
|
+
with pytest.raises(RuntimeError, match="cloudflared not found in PATH"):
|
|
13
|
+
provider.start(8999)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@unittest.mock.patch("subprocess.run")
|
|
17
|
+
@unittest.mock.patch("subprocess.Popen")
|
|
18
|
+
@unittest.mock.patch("time.time")
|
|
19
|
+
def test_cloudflare_provider_success(mock_time, mock_popen, mock_run):
|
|
20
|
+
mock_run.return_value.returncode = 0
|
|
21
|
+
mock_time.side_effect = [0, 1, 2]
|
|
22
|
+
|
|
23
|
+
mock_process = unittest.mock.MagicMock()
|
|
24
|
+
mock_process.stdout.readline.side_effect = [
|
|
25
|
+
"Starting tunnel...",
|
|
26
|
+
"https://mocked.trycloudflare.com",
|
|
27
|
+
"",
|
|
28
|
+
]
|
|
29
|
+
mock_popen.return_value = mock_process
|
|
30
|
+
|
|
31
|
+
provider = providers.CloudflareProvider()
|
|
32
|
+
url = provider.start(8999)
|
|
33
|
+
|
|
34
|
+
assert url == "https://mocked.trycloudflare.com"
|
|
35
|
+
assert provider.process is not None
|
|
36
|
+
provider.stop()
|
|
37
|
+
assert provider.process is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@unittest.mock.patch("subprocess.run")
|
|
41
|
+
def test_tailscale_provider_binary_missing(mock_run):
|
|
42
|
+
mock_run.return_value.returncode = 1
|
|
43
|
+
provider = providers.TailscaleProvider()
|
|
44
|
+
with pytest.raises(RuntimeError, match="tailscale not found in PATH"):
|
|
45
|
+
provider.start(8999)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@unittest.mock.patch("subprocess.run")
|
|
49
|
+
def test_tailscale_provider_success(mock_run):
|
|
50
|
+
# Mock which tailscale
|
|
51
|
+
# Mock tailscale serve
|
|
52
|
+
# Mock tailscale funnel
|
|
53
|
+
# Mock tailscale status
|
|
54
|
+
mock_run.side_effect = [
|
|
55
|
+
unittest.mock.MagicMock(returncode=0), # which
|
|
56
|
+
unittest.mock.MagicMock(returncode=0), # serve
|
|
57
|
+
unittest.mock.MagicMock(returncode=0), # funnel
|
|
58
|
+
unittest.mock.MagicMock(
|
|
59
|
+
returncode=0,
|
|
60
|
+
stdout='{"Self": {"DNSName": "my-node.tail-scale.net."}}',
|
|
61
|
+
), # status
|
|
62
|
+
unittest.mock.MagicMock(returncode=0), # stop: funnel off
|
|
63
|
+
unittest.mock.MagicMock(returncode=0), # stop: serve reset
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
provider = providers.TailscaleProvider()
|
|
67
|
+
url = provider.start(8999)
|
|
68
|
+
|
|
69
|
+
assert url == "https://my-node.tail-scale.net:8999"
|
|
70
|
+
|
|
71
|
+
# Test stop
|
|
72
|
+
provider.stop()
|
|
73
|
+
assert mock_run.call_count == 6 # 4 in start + 2 in stop
|