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/web/index.js
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
(async () => {
|
|
2
|
+
// Mancha.initMancha is the robust way to initialize.
|
|
3
|
+
window.ManchaApp = Mancha.initMancha({
|
|
4
|
+
cloak: true,
|
|
5
|
+
callback: async (renderer) => {
|
|
6
|
+
const { $ } = renderer;
|
|
7
|
+
|
|
8
|
+
// --- Project Scoping ---
|
|
9
|
+
// $$project is auto-synced with the ?project= URL query param by mancha.
|
|
10
|
+
$.$$project = $.$$project ?? '';
|
|
11
|
+
|
|
12
|
+
// Helper: build a URL with the project query param.
|
|
13
|
+
function apiUrl(path, extraParams = {}) {
|
|
14
|
+
const params = new URLSearchParams(extraParams);
|
|
15
|
+
if ($.$$project) params.set('project', $.$$project);
|
|
16
|
+
const qs = params.toString();
|
|
17
|
+
return qs ? `${path}?${qs}` : path;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Persistence Management (scoped by project) ---
|
|
21
|
+
const Storage = {
|
|
22
|
+
getPrefix() {
|
|
23
|
+
return `lemming[${$.$$project}]_`;
|
|
24
|
+
},
|
|
25
|
+
get(key, fallback) {
|
|
26
|
+
try {
|
|
27
|
+
const val = localStorage.getItem(this.getPrefix() + key);
|
|
28
|
+
return val !== null ? JSON.parse(val) : fallback;
|
|
29
|
+
} catch {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
set(key, val) {
|
|
34
|
+
localStorage.setItem(this.getPrefix() + key, JSON.stringify(val));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// --- Initial State ---
|
|
39
|
+
$.tasks = [];
|
|
40
|
+
$.context = '';
|
|
41
|
+
$.config = {
|
|
42
|
+
retries: 3,
|
|
43
|
+
runner: 'agy',
|
|
44
|
+
hooks: ['roadmap'],
|
|
45
|
+
};
|
|
46
|
+
$.cwd = '';
|
|
47
|
+
$.newTask = '';
|
|
48
|
+
$.loading = true;
|
|
49
|
+
$.runners = [];
|
|
50
|
+
$.availableHooks = [];
|
|
51
|
+
$.selectedRunner = 'agy';
|
|
52
|
+
$.retries = 3;
|
|
53
|
+
$.timeLimit = '60';
|
|
54
|
+
$.envOverrides = []; // Will hydrate below
|
|
55
|
+
$.hideCompleted = false; // Hydrate after mount
|
|
56
|
+
$.toasts = [];
|
|
57
|
+
$.expanded = {};
|
|
58
|
+
$.loopRunning = false;
|
|
59
|
+
$.editingTask = null;
|
|
60
|
+
$.editFormData = { description: '', parent: '' };
|
|
61
|
+
|
|
62
|
+
// --- Favicon Status ---
|
|
63
|
+
$.faviconState = 'idle';
|
|
64
|
+
$.lastSeenState = null; // Hydrate after mount
|
|
65
|
+
// --- Folder Picker State ---
|
|
66
|
+
$.folderPickerPath = '';
|
|
67
|
+
$.folderPickerDirs = [];
|
|
68
|
+
$.folderPickerLoading = false;
|
|
69
|
+
$.showNewFolderInput = false;
|
|
70
|
+
$.newFolderName = '';
|
|
71
|
+
|
|
72
|
+
// --- Computed Properties ---
|
|
73
|
+
$.runningCount = $.$computed(
|
|
74
|
+
($) => $.tasks.filter((t) => t.status === 'in_progress').length,
|
|
75
|
+
);
|
|
76
|
+
$.pendingCount = $.$computed(
|
|
77
|
+
($) => $.tasks.filter((t) => t.status === 'pending').length,
|
|
78
|
+
);
|
|
79
|
+
$.completedCount = $.$computed(
|
|
80
|
+
($) => $.tasks.filter((t) => t.status === 'completed').length,
|
|
81
|
+
);
|
|
82
|
+
$.failedCount = $.$computed(
|
|
83
|
+
($) =>
|
|
84
|
+
$.tasks.filter(
|
|
85
|
+
(t) => t.status === 'failed' || t.status === 'cancelled',
|
|
86
|
+
).length,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
$.filteredTasks = $.$computed(($) => {
|
|
90
|
+
const ts = [...$.tasks];
|
|
91
|
+
// Sort in frontend to ensure consistent order:
|
|
92
|
+
// 1. Uncompleted tasks (pending, in_progress) first.
|
|
93
|
+
// 2. Completed tasks (completed, failed) at the bottom.
|
|
94
|
+
ts.sort((a, b) => {
|
|
95
|
+
const aDone =
|
|
96
|
+
a.status === 'completed' ||
|
|
97
|
+
a.status === 'failed' ||
|
|
98
|
+
a.status === 'cancelled'
|
|
99
|
+
? 1
|
|
100
|
+
: 0;
|
|
101
|
+
const bDone =
|
|
102
|
+
b.status === 'completed' ||
|
|
103
|
+
b.status === 'failed' ||
|
|
104
|
+
b.status === 'cancelled'
|
|
105
|
+
? 1
|
|
106
|
+
: 0;
|
|
107
|
+
if (aDone !== bDone) return aDone - bDone;
|
|
108
|
+
|
|
109
|
+
if (!aDone) {
|
|
110
|
+
// Uncompleted tasks: prioritize in_progress first, then FIFO (index)
|
|
111
|
+
const aInProgress = a.status === 'in_progress' ? 0 : 1;
|
|
112
|
+
const bInProgress = b.status === 'in_progress' ? 0 : 1;
|
|
113
|
+
if (aInProgress !== bInProgress) return aInProgress - bInProgress;
|
|
114
|
+
|
|
115
|
+
if (a.index !== b.index) return (a.index || 0) - (b.index || 0);
|
|
116
|
+
return (a.created_at || 0) - (b.created_at || 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Completed tasks: newest first (reverse chronological by completion/creation time).
|
|
120
|
+
const aTime = a.completed_at || a.created_at || 0;
|
|
121
|
+
const bTime = b.completed_at || b.created_at || 0;
|
|
122
|
+
if (aTime !== bTime) return bTime - aTime;
|
|
123
|
+
|
|
124
|
+
return (b.index || 0) - (a.index || 0);
|
|
125
|
+
});
|
|
126
|
+
return ts.filter(
|
|
127
|
+
(t) =>
|
|
128
|
+
(t.status !== 'completed' &&
|
|
129
|
+
t.status !== 'failed' &&
|
|
130
|
+
t.status !== 'cancelled') ||
|
|
131
|
+
!$.hideCompleted,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// --- Utilities ---
|
|
136
|
+
$.trim = (s, l = 60) =>
|
|
137
|
+
s && s.length > l ? `${s.substring(0, l - 3)}...` : s;
|
|
138
|
+
$.formatDate = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '');
|
|
139
|
+
$.formatDuration = (seconds) => {
|
|
140
|
+
if (!seconds) return '0s';
|
|
141
|
+
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
142
|
+
const minutes = Math.floor(seconds / 60);
|
|
143
|
+
const remainingSeconds = Math.floor(seconds % 60);
|
|
144
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
145
|
+
};
|
|
146
|
+
$.formatTaskRunTime = (task) => {
|
|
147
|
+
let total = task.run_time || 0;
|
|
148
|
+
if (task.status === 'in_progress' && task.last_started_at) {
|
|
149
|
+
total += Date.now() / 1000 - task.last_started_at;
|
|
150
|
+
}
|
|
151
|
+
return $.formatDuration(total);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
$.getParent = (parentId) => {
|
|
155
|
+
return $.tasks.find((t) => t.id === parentId);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
$.copyToClipboard = function (text) {
|
|
159
|
+
if (!navigator.clipboard) {
|
|
160
|
+
const el = document.createElement('textarea');
|
|
161
|
+
el.value = text;
|
|
162
|
+
document.body.appendChild(el);
|
|
163
|
+
el.select();
|
|
164
|
+
document.execCommand('copy');
|
|
165
|
+
document.body.removeChild(el);
|
|
166
|
+
} else {
|
|
167
|
+
navigator.clipboard.writeText(text);
|
|
168
|
+
}
|
|
169
|
+
this.addToast('Copied to clipboard', 'info');
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// --- UI Feedback ---
|
|
173
|
+
$.addToast = function (message, type = 'info') {
|
|
174
|
+
const id = Date.now() + Math.random();
|
|
175
|
+
this.toasts.push({ id, message: this.trim(message, 120), type });
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
this.toasts = this.toasts.filter((t) => t.id !== id);
|
|
178
|
+
}, 5000);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// --- Data Actions ---
|
|
182
|
+
$.notifyChanges = (oldTasks, newTasks) => {
|
|
183
|
+
if ($.loading || !oldTasks.length) return;
|
|
184
|
+
const oldTaskMap = new Map(oldTasks.map((t) => [t.id, t]));
|
|
185
|
+
|
|
186
|
+
for (const newTask of newTasks) {
|
|
187
|
+
const oldTask = oldTaskMap.get(newTask.id);
|
|
188
|
+
if (!oldTask) continue;
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
oldTask.status !== 'completed' &&
|
|
192
|
+
newTask.status === 'completed'
|
|
193
|
+
) {
|
|
194
|
+
$.addToast(
|
|
195
|
+
`Task completed: ${$.trim(newTask.description, 60)}`,
|
|
196
|
+
'success',
|
|
197
|
+
);
|
|
198
|
+
} else if (
|
|
199
|
+
oldTask.status !== 'failed' &&
|
|
200
|
+
newTask.status === 'failed'
|
|
201
|
+
) {
|
|
202
|
+
$.addToast(
|
|
203
|
+
`Terminal failure: ${$.trim(newTask.description, 60)}`,
|
|
204
|
+
'error',
|
|
205
|
+
);
|
|
206
|
+
} else if (
|
|
207
|
+
oldTask.status !== 'cancelled' &&
|
|
208
|
+
newTask.status === 'cancelled'
|
|
209
|
+
) {
|
|
210
|
+
$.addToast(
|
|
211
|
+
`Task cancelled: ${$.trim(newTask.description, 60)}`,
|
|
212
|
+
'info',
|
|
213
|
+
);
|
|
214
|
+
} else if (
|
|
215
|
+
oldTask.status === 'in_progress' &&
|
|
216
|
+
newTask.status === 'pending'
|
|
217
|
+
) {
|
|
218
|
+
$.addToast(
|
|
219
|
+
`Attempt failed (retry pending): ${$.trim(newTask.description, 60)}`,
|
|
220
|
+
'error',
|
|
221
|
+
);
|
|
222
|
+
} else if (
|
|
223
|
+
(newTask.progress?.length || 0) > (oldTask.progress?.length || 0)
|
|
224
|
+
) {
|
|
225
|
+
$.addToast(
|
|
226
|
+
`Progress recorded: ${$.trim(newTask.progress[newTask.progress.length - 1], 60)}`,
|
|
227
|
+
'info',
|
|
228
|
+
);
|
|
229
|
+
} else if (newTask.attempts > oldTask.attempts) {
|
|
230
|
+
$.addToast(
|
|
231
|
+
`Task attempt ${newTask.attempts}: ${$.trim(newTask.description, 60)}`,
|
|
232
|
+
'info',
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
$.updateTitle = () => {
|
|
239
|
+
const project = $.$$project;
|
|
240
|
+
let folderName = '';
|
|
241
|
+
if (project) {
|
|
242
|
+
folderName = project.split('/').filter(Boolean)[0];
|
|
243
|
+
} else if ($.cwd) {
|
|
244
|
+
folderName = $.cwd.split('/').filter(Boolean).pop();
|
|
245
|
+
}
|
|
246
|
+
document.title = folderName ? `Lemming · ${folderName}` : 'Lemming';
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
$.updateFaviconStatus = () => {
|
|
250
|
+
if (!window.updateFavicon) return;
|
|
251
|
+
const hasError = $.tasks.some(
|
|
252
|
+
(t) =>
|
|
253
|
+
(t.status === 'pending' && t.attempts > 0) ||
|
|
254
|
+
t.status === 'cancelled',
|
|
255
|
+
);
|
|
256
|
+
const allCompleted =
|
|
257
|
+
$.tasks.length > 0 && $.tasks.every((t) => t.status === 'completed');
|
|
258
|
+
const state = $.loopRunning
|
|
259
|
+
? 'running'
|
|
260
|
+
: hasError
|
|
261
|
+
? 'error'
|
|
262
|
+
: allCompleted
|
|
263
|
+
? 'success'
|
|
264
|
+
: 'idle';
|
|
265
|
+
|
|
266
|
+
$.faviconState = state;
|
|
267
|
+
if (state === 'running') {
|
|
268
|
+
$.lastSeenState = null;
|
|
269
|
+
Storage.set('last_seen_state', null);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const effectiveState =
|
|
273
|
+
(state === 'success' || state === 'error') &&
|
|
274
|
+
state === $.lastSeenState
|
|
275
|
+
? 'idle'
|
|
276
|
+
: state;
|
|
277
|
+
window.updateFavicon(effectiveState);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
$.fetchData = async () => {
|
|
281
|
+
const response = await fetch(apiUrl('/api/data'));
|
|
282
|
+
if (!response.ok) return;
|
|
283
|
+
const data = await response.json();
|
|
284
|
+
const newTasks = data.tasks || [];
|
|
285
|
+
|
|
286
|
+
// Guard against transient empty responses from the server
|
|
287
|
+
// (e.g. reading the YAML file mid-write). Skip the update entirely
|
|
288
|
+
// so the UI doesn't flash empty and fire rogue notifications.
|
|
289
|
+
if (!newTasks.length && $.tasks.length) return;
|
|
290
|
+
|
|
291
|
+
$.notifyChanges($.tasks, newTasks);
|
|
292
|
+
|
|
293
|
+
// Update core state
|
|
294
|
+
$.cwd = data.cwd || '';
|
|
295
|
+
$.loopRunning = data.loop_running || false;
|
|
296
|
+
$.tasks = newTasks;
|
|
297
|
+
|
|
298
|
+
// Sync config from server
|
|
299
|
+
if (data.config) {
|
|
300
|
+
$.config = data.config;
|
|
301
|
+
|
|
302
|
+
const runnerElem = document.querySelector(
|
|
303
|
+
'input[aria-label="Runner command"]',
|
|
304
|
+
);
|
|
305
|
+
if (
|
|
306
|
+
$.loading ||
|
|
307
|
+
!runnerElem ||
|
|
308
|
+
document.activeElement !== runnerElem
|
|
309
|
+
) {
|
|
310
|
+
$.selectedRunner = data.config.runner;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const retriesElem = document.querySelector(
|
|
314
|
+
'input[aria-label="Retries"]',
|
|
315
|
+
);
|
|
316
|
+
if (
|
|
317
|
+
$.loading ||
|
|
318
|
+
!retriesElem ||
|
|
319
|
+
document.activeElement !== retriesElem
|
|
320
|
+
) {
|
|
321
|
+
$.retries = data.config.retries;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const timeLimitElem = document.querySelector(
|
|
325
|
+
'select[aria-label="Time limit"]',
|
|
326
|
+
);
|
|
327
|
+
if (
|
|
328
|
+
$.loading ||
|
|
329
|
+
!timeLimitElem ||
|
|
330
|
+
document.activeElement !== timeLimitElem
|
|
331
|
+
) {
|
|
332
|
+
$.timeLimit = String(data.config.time_limit || 0);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
$.updateTitle();
|
|
337
|
+
$.updateFaviconStatus();
|
|
338
|
+
|
|
339
|
+
const contextElem = document.querySelector('textarea');
|
|
340
|
+
if (
|
|
341
|
+
$.loading ||
|
|
342
|
+
(contextElem && document.activeElement !== contextElem)
|
|
343
|
+
) {
|
|
344
|
+
$.context = data.context || '';
|
|
345
|
+
}
|
|
346
|
+
$.loading = false;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
$.fetchRunners = async () => {
|
|
350
|
+
const response = await fetch(apiUrl('/api/runners'));
|
|
351
|
+
if (response.ok) {
|
|
352
|
+
$.runners = await response.json();
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
$.fetchHooks = async () => {
|
|
357
|
+
const response = await fetch(apiUrl('/api/hooks'));
|
|
358
|
+
if (response.ok) {
|
|
359
|
+
$.availableHooks = await response.json();
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
$.saveConfigToServer = async () => {
|
|
364
|
+
const config = {
|
|
365
|
+
retries: Number.parseInt($.retries, 10) || 3,
|
|
366
|
+
runner: $.selectedRunner,
|
|
367
|
+
hooks: $.config.hooks,
|
|
368
|
+
time_limit: Number.parseInt($.timeLimit, 10) || 0,
|
|
369
|
+
};
|
|
370
|
+
await fetch(apiUrl('/api/config'), {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: { 'Content-Type': 'application/json' },
|
|
373
|
+
body: JSON.stringify(config),
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
$.saveRunnerPreference = () => {
|
|
378
|
+
$.saveConfigToServer();
|
|
379
|
+
};
|
|
380
|
+
$.saveRetriesPreference = () => {
|
|
381
|
+
$.saveConfigToServer();
|
|
382
|
+
};
|
|
383
|
+
$.saveTimeLimitPreference = () => {
|
|
384
|
+
$.saveConfigToServer();
|
|
385
|
+
};
|
|
386
|
+
$.saveHideCompletedPreference = () => {
|
|
387
|
+
Storage.set('hide_completed', $.hideCompleted);
|
|
388
|
+
};
|
|
389
|
+
$.toggleHook = (name) => {
|
|
390
|
+
let hooks = $.config.hooks;
|
|
391
|
+
if (hooks === null || hooks === undefined) {
|
|
392
|
+
hooks = [...$.availableHooks];
|
|
393
|
+
}
|
|
394
|
+
if (hooks.includes(name)) {
|
|
395
|
+
$.config.hooks = hooks.filter((h) => h !== name);
|
|
396
|
+
} else {
|
|
397
|
+
$.config.hooks = [...hooks, name];
|
|
398
|
+
}
|
|
399
|
+
$.saveConfigToServer();
|
|
400
|
+
};
|
|
401
|
+
$.resetHooks = () => {
|
|
402
|
+
$.config.hooks = null;
|
|
403
|
+
$.saveConfigToServer();
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
let envSaveTimeout;
|
|
407
|
+
$.saveEnvOverrides = () => {
|
|
408
|
+
clearTimeout(envSaveTimeout);
|
|
409
|
+
envSaveTimeout = setTimeout(() => {
|
|
410
|
+
const toSave = $.envOverrides.map(({ key, value }) => ({
|
|
411
|
+
key,
|
|
412
|
+
value,
|
|
413
|
+
}));
|
|
414
|
+
Storage.set('env_overrides', toSave);
|
|
415
|
+
}, 300);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// --- Operations ---
|
|
419
|
+
$.addEnvOverride = () => {
|
|
420
|
+
const id =
|
|
421
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
422
|
+
? crypto.randomUUID()
|
|
423
|
+
: `env-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
424
|
+
$.envOverrides.push({ id, key: '', value: '' });
|
|
425
|
+
$.saveEnvOverrides();
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
$.removeEnvOverride = (index) => {
|
|
429
|
+
$.envOverrides.splice(index, 1);
|
|
430
|
+
$.saveEnvOverrides();
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
$.addTask = async () => {
|
|
434
|
+
if (!$.newTask.trim()) return;
|
|
435
|
+
const res = await fetch(apiUrl('/api/tasks'), {
|
|
436
|
+
method: 'POST',
|
|
437
|
+
headers: { 'Content-Type': 'application/json' },
|
|
438
|
+
body: JSON.stringify({ description: $.newTask }),
|
|
439
|
+
});
|
|
440
|
+
if (res.ok) {
|
|
441
|
+
$.newTask = '';
|
|
442
|
+
await $.fetchData();
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
$.taskActionTarget = null;
|
|
447
|
+
$.taskActionTargetStatus = null;
|
|
448
|
+
$.taskActionTargetHasAttempts = false;
|
|
449
|
+
|
|
450
|
+
$.openTaskActionModal = (id) => {
|
|
451
|
+
$.taskActionTarget = id;
|
|
452
|
+
const task = $.tasks.find((t) => t.id === id);
|
|
453
|
+
$.taskActionTargetStatus = task ? task.status : null;
|
|
454
|
+
$.taskActionTargetHasAttempts = task ? task.attempts > 0 : false;
|
|
455
|
+
const modal = document.getElementById('task-action-modal');
|
|
456
|
+
if (modal) modal.showModal();
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
$.closeTaskActionModal = () => {
|
|
460
|
+
const modal = document.getElementById('task-action-modal');
|
|
461
|
+
if (modal) modal.close();
|
|
462
|
+
$.taskActionTarget = null;
|
|
463
|
+
$.taskActionTargetStatus = null;
|
|
464
|
+
$.taskActionTargetHasAttempts = false;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
$.editTaskFromModal = () => {
|
|
468
|
+
const task = $.tasks.find((t) => t.id === $.taskActionTarget);
|
|
469
|
+
$.closeTaskActionModal();
|
|
470
|
+
if (task) $.editTask(task);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
$.clearTaskFromModal = () => {
|
|
474
|
+
const id = $.taskActionTarget;
|
|
475
|
+
$.closeTaskActionModal();
|
|
476
|
+
if (id) $.clearTask(id);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
$.deleteTask = async (id) => {
|
|
480
|
+
const res = await fetch(apiUrl(`/api/tasks/${id}/delete`), {
|
|
481
|
+
method: 'POST',
|
|
482
|
+
});
|
|
483
|
+
if (res.ok) await $.fetchData();
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
$.confirmDeleteTask = async () => {
|
|
487
|
+
if (!$.taskActionTarget) return;
|
|
488
|
+
await $.deleteTask($.taskActionTarget);
|
|
489
|
+
$.closeTaskActionModal();
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
$.confirmCancelTask = async () => {
|
|
493
|
+
if (!$.taskActionTarget) return;
|
|
494
|
+
const id = $.taskActionTarget;
|
|
495
|
+
const res = await fetch(apiUrl(`/api/tasks/${id}/cancel`), {
|
|
496
|
+
method: 'POST',
|
|
497
|
+
});
|
|
498
|
+
if (res.ok) {
|
|
499
|
+
$.addToast('Execution cancelled', 'info');
|
|
500
|
+
await $.fetchData();
|
|
501
|
+
}
|
|
502
|
+
$.closeTaskActionModal();
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
$.confirmReopenTask = async () => {
|
|
506
|
+
if (!$.taskActionTarget) return;
|
|
507
|
+
const id = $.taskActionTarget;
|
|
508
|
+
const res = await fetch(apiUrl(`/api/tasks/${id}/update`), {
|
|
509
|
+
method: 'POST',
|
|
510
|
+
headers: { 'Content-Type': 'application/json' },
|
|
511
|
+
body: JSON.stringify({ status: 'pending' }),
|
|
512
|
+
});
|
|
513
|
+
if (res.ok) {
|
|
514
|
+
$.addToast('Task re-opened', 'info');
|
|
515
|
+
await $.fetchData();
|
|
516
|
+
}
|
|
517
|
+
$.closeTaskActionModal();
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
$.deleteCompletedTasks = async () => {
|
|
521
|
+
if (confirm('Delete ALL completed tasks?')) {
|
|
522
|
+
const res = await fetch(apiUrl('/api/tasks/delete-completed'), {
|
|
523
|
+
method: 'POST',
|
|
524
|
+
});
|
|
525
|
+
if (res.ok) {
|
|
526
|
+
$.addToast('Completed tasks deleted', 'success');
|
|
527
|
+
await $.fetchData();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
$.cancelTask = async (id) => {
|
|
533
|
+
$.openTaskActionModal(id);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
$.editTask = (task) => {
|
|
537
|
+
$.editingTask = task;
|
|
538
|
+
$.editFormData = {
|
|
539
|
+
description: task.description || '',
|
|
540
|
+
parent: task.parent || '',
|
|
541
|
+
};
|
|
542
|
+
const modal = document.getElementById('edit-modal');
|
|
543
|
+
if (modal) modal.showModal();
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
$.closeEditModal = () => {
|
|
547
|
+
const modal = document.getElementById('edit-modal');
|
|
548
|
+
if (modal) modal.close();
|
|
549
|
+
$.editingTask = null;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
$.submitEditTask = async () => {
|
|
553
|
+
if (!$.editingTask) return;
|
|
554
|
+
|
|
555
|
+
const task = $.editingTask;
|
|
556
|
+
const update = {
|
|
557
|
+
description: $.editFormData.description.trim() || task.description,
|
|
558
|
+
parent: $.editFormData.parent.trim() || null,
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const res = await fetch(apiUrl(`/api/tasks/${task.id}/update`), {
|
|
562
|
+
method: 'POST',
|
|
563
|
+
headers: { 'Content-Type': 'application/json' },
|
|
564
|
+
body: JSON.stringify(update),
|
|
565
|
+
});
|
|
566
|
+
if (res.ok) {
|
|
567
|
+
$.addToast('Task updated', 'success');
|
|
568
|
+
await $.fetchData();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
$.closeEditModal();
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
$.uncompleteTask = async (id) => {
|
|
575
|
+
const res = await fetch(apiUrl(`/api/tasks/${id}/update`), {
|
|
576
|
+
method: 'POST',
|
|
577
|
+
headers: { 'Content-Type': 'application/json' },
|
|
578
|
+
body: JSON.stringify({ status: 'pending' }),
|
|
579
|
+
});
|
|
580
|
+
if (res.ok) {
|
|
581
|
+
$.addToast('Task reset to pending', 'info');
|
|
582
|
+
await $.fetchData();
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
$.clearTask = async (id) => {
|
|
587
|
+
if (confirm('Clear task attempts and progress?')) {
|
|
588
|
+
const res = await fetch(apiUrl(`/api/tasks/${id}/clear`), {
|
|
589
|
+
method: 'POST',
|
|
590
|
+
});
|
|
591
|
+
if (res.ok) {
|
|
592
|
+
$.addToast('Task cleared', 'success');
|
|
593
|
+
await $.fetchData();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
$.ctxSaveTimeout = null;
|
|
599
|
+
$.updateContext = () => {
|
|
600
|
+
clearTimeout($.ctxSaveTimeout);
|
|
601
|
+
$.ctxSaveTimeout = setTimeout(async () => {
|
|
602
|
+
const res = await fetch(apiUrl('/api/context'), {
|
|
603
|
+
method: 'POST',
|
|
604
|
+
headers: { 'Content-Type': 'application/json' },
|
|
605
|
+
body: JSON.stringify({ context: $.context }),
|
|
606
|
+
});
|
|
607
|
+
if (res.ok) $.addToast('Context saved', 'info');
|
|
608
|
+
}, 1000);
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
$.runLemming = async () => {
|
|
612
|
+
const env = {};
|
|
613
|
+
for (const o of $.envOverrides) {
|
|
614
|
+
if (o.key?.trim()) env[o.key.trim()] = o.value || '';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const payload = {
|
|
618
|
+
env: Object.keys(env).length > 0 ? env : undefined,
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const res = await fetch(apiUrl('/api/run'), {
|
|
622
|
+
method: 'POST',
|
|
623
|
+
headers: { 'Content-Type': 'application/json' },
|
|
624
|
+
body: JSON.stringify(payload),
|
|
625
|
+
});
|
|
626
|
+
if (res.ok) {
|
|
627
|
+
$.addToast('Run started!', 'success');
|
|
628
|
+
await $.fetchData();
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// --- Folder Picker ---
|
|
633
|
+
$.openFolderPicker = async () => {
|
|
634
|
+
$.folderPickerPath = '';
|
|
635
|
+
$.showNewFolderInput = false;
|
|
636
|
+
$.newFolderName = '';
|
|
637
|
+
await $.fetchFolderPickerDirs('');
|
|
638
|
+
const modal = document.getElementById('folder-picker-modal');
|
|
639
|
+
if (modal) modal.showModal();
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
$.closeFolderPicker = () => {
|
|
643
|
+
const modal = document.getElementById('folder-picker-modal');
|
|
644
|
+
if (modal) modal.close();
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
$.startNewFolder = () => {
|
|
648
|
+
$.showNewFolderInput = true;
|
|
649
|
+
$.newFolderName = '';
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
$.fetchFolderPickerDirs = async (path) => {
|
|
653
|
+
$.folderPickerLoading = true;
|
|
654
|
+
const params = new URLSearchParams(path ? { path } : {});
|
|
655
|
+
const res = await fetch(`/api/directories?${params.toString()}`);
|
|
656
|
+
if (res.ok) {
|
|
657
|
+
const data = await res.json();
|
|
658
|
+
$.folderPickerPath = data.path;
|
|
659
|
+
$.folderPickerDirs = data.directories;
|
|
660
|
+
}
|
|
661
|
+
$.folderPickerLoading = false;
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
$.createFolder = async () => {
|
|
665
|
+
if (!$.newFolderName) return;
|
|
666
|
+
const res = await fetch('/api/directories', {
|
|
667
|
+
method: 'POST',
|
|
668
|
+
headers: { 'Content-Type': 'application/json' },
|
|
669
|
+
body: JSON.stringify({
|
|
670
|
+
path: $.folderPickerPath,
|
|
671
|
+
name: $.newFolderName,
|
|
672
|
+
}),
|
|
673
|
+
});
|
|
674
|
+
if (res.ok) {
|
|
675
|
+
$.addToast('Folder created!', 'success');
|
|
676
|
+
$.showNewFolderInput = false;
|
|
677
|
+
$.newFolderName = '';
|
|
678
|
+
await $.fetchFolderPickerDirs($.folderPickerPath);
|
|
679
|
+
} else {
|
|
680
|
+
const err = await res.json();
|
|
681
|
+
$.addToast(err.detail || 'Failed to create folder', 'error');
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
$.folderPickerNavigate = async (path) => {
|
|
685
|
+
await $.fetchFolderPickerDirs(path);
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
$.folderPickerUp = async () => {
|
|
689
|
+
const parts = $.folderPickerPath.split('/').filter(Boolean);
|
|
690
|
+
parts.pop();
|
|
691
|
+
await $.fetchFolderPickerDirs(parts.join('/'));
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
$.folderPickerSelect = (path) => {
|
|
695
|
+
// Navigate to the same page with the new project param.
|
|
696
|
+
const url = new URL(window.location.href);
|
|
697
|
+
if (path) {
|
|
698
|
+
url.searchParams.set('project', path);
|
|
699
|
+
} else {
|
|
700
|
+
url.searchParams.delete('project');
|
|
701
|
+
}
|
|
702
|
+
window.open(url.toString(), '_blank');
|
|
703
|
+
$.closeFolderPicker();
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
$.folderPickerBreadcrumbs = $.$computed(($) => {
|
|
707
|
+
const parts = $.folderPickerPath.split('/').filter(Boolean);
|
|
708
|
+
const crumbs = [{ name: 'root', path: '' }];
|
|
709
|
+
for (let i = 0; i < parts.length; i++) {
|
|
710
|
+
crumbs.push({
|
|
711
|
+
name: parts[i],
|
|
712
|
+
path: parts.slice(0, i + 1).join('/'),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return crumbs;
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// --- Mount to DOM (syncs $$project from URL) ---
|
|
719
|
+
await renderer.mount(document.body);
|
|
720
|
+
|
|
721
|
+
// --- Final Hydration from Storage (Post-Mount) ---
|
|
722
|
+
$.hideCompleted = Storage.get('hide_completed', false);
|
|
723
|
+
$.lastSeenState = Storage.get('last_seen_state', null);
|
|
724
|
+
const loadedOverrides = Storage.get('env_overrides', []);
|
|
725
|
+
if (loadedOverrides.length > 0) {
|
|
726
|
+
$.envOverrides = loadedOverrides.map((o, i) => ({
|
|
727
|
+
...o,
|
|
728
|
+
id: o.id || `env-${i}`,
|
|
729
|
+
}));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// --- Initial Data Fetch (after mount so $$project is available) ---
|
|
733
|
+
await Promise.all([$.fetchData(), $.fetchRunners(), $.fetchHooks()]);
|
|
734
|
+
|
|
735
|
+
// --- Auto-refresh via polling ---
|
|
736
|
+
document.addEventListener('visibilitychange', () => {
|
|
737
|
+
if (document.visibilityState === 'visible') {
|
|
738
|
+
// Force an immediate fetch when returning to the tab.
|
|
739
|
+
$.fetchData();
|
|
740
|
+
|
|
741
|
+
const state = $.faviconState;
|
|
742
|
+
if (state === 'success' || state === 'error') {
|
|
743
|
+
$.lastSeenState = state;
|
|
744
|
+
Storage.set('last_seen_state', state);
|
|
745
|
+
if (window.updateFavicon) window.updateFavicon('idle');
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
setInterval(() => $.fetchData(), 1000);
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
})();
|