loop-task 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +279 -195
  2. package/dist/board/App.js +225 -87
  3. package/dist/board/components/ActionButtons.js +28 -8
  4. package/dist/board/components/CreateForm.js +230 -70
  5. package/dist/board/components/CreateProjectModal.js +104 -0
  6. package/dist/board/components/DeleteProjectConfirm.js +53 -0
  7. package/dist/board/components/EditProjectModal.js +106 -0
  8. package/dist/board/components/FilterBar.js +5 -4
  9. package/dist/board/components/Footer.js +19 -6
  10. package/dist/board/components/Header.js +33 -3
  11. package/dist/board/components/HelpModal.js +11 -10
  12. package/dist/board/components/Inspector.js +1 -1
  13. package/dist/board/components/LogModal.js +39 -8
  14. package/dist/board/components/Navigator.js +30 -14
  15. package/dist/board/components/ProjectsModal.js +70 -0
  16. package/dist/board/components/ProjectsPage.js +105 -0
  17. package/dist/board/components/RunHistory.js +48 -9
  18. package/dist/board/components/TaskBrowser.js +96 -0
  19. package/dist/board/components/TaskFilterBar.js +6 -0
  20. package/dist/board/components/TaskForm.js +175 -0
  21. package/dist/board/daemon.js +56 -0
  22. package/dist/board/format.js +19 -12
  23. package/dist/board/hooks/useBoardKeybindings.js +264 -156
  24. package/dist/board/hooks/useTaskKeybindings.js +130 -0
  25. package/dist/board/router.js +16 -0
  26. package/dist/board/state.js +18 -14
  27. package/dist/cli.js +61 -6
  28. package/dist/client/commands.js +157 -1
  29. package/dist/config/constants.js +15 -0
  30. package/dist/config/paths.js +6 -0
  31. package/dist/core/command-runner.js +2 -0
  32. package/dist/core/foreground-loop.js +4 -1
  33. package/dist/core/loop-controller.js +210 -32
  34. package/dist/daemon/index.js +5 -2
  35. package/dist/daemon/manager.js +113 -34
  36. package/dist/daemon/projects.js +104 -0
  37. package/dist/daemon/server.js +177 -6
  38. package/dist/daemon/state.js +32 -1
  39. package/dist/daemon/task-manager.js +58 -0
  40. package/dist/i18n/en.json +140 -21
  41. package/dist/loop-config.js +13 -4
  42. package/dist/shared/clipboard.js +19 -0
  43. package/package.json +13 -3
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs";
2
+ import { writeFileAtomic } from "../shared/fs-utils.js";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { daemonLog } from "./daemon-log.js";
6
+ import crypto from "node:crypto";
7
+ export class ProjectManager {
8
+ projects = new Map();
9
+ projectsDir;
10
+ constructor(loopCliHome) {
11
+ const baseDir = loopCliHome || join(homedir(), ".loop-cli");
12
+ this.projectsDir = join(baseDir, "projects");
13
+ }
14
+ init() {
15
+ if (!fs.existsSync(this.projectsDir)) {
16
+ fs.mkdirSync(this.projectsDir, { recursive: true });
17
+ }
18
+ this.loadProjects();
19
+ if (!this.projects.has("default")) {
20
+ this.createDefaultProject();
21
+ }
22
+ }
23
+ loadProjects() {
24
+ this.projects.clear();
25
+ if (!fs.existsSync(this.projectsDir))
26
+ return;
27
+ try {
28
+ const files = fs.readdirSync(this.projectsDir);
29
+ for (const file of files) {
30
+ if (!file.endsWith(".json"))
31
+ continue;
32
+ try {
33
+ const raw = fs.readFileSync(join(this.projectsDir, file), "utf-8");
34
+ const content = JSON.parse(raw);
35
+ if (content.id)
36
+ this.projects.set(content.id, content);
37
+ }
38
+ catch (err) {
39
+ daemonLog(`Failed to load project ${file}: ${err}`);
40
+ }
41
+ }
42
+ }
43
+ catch (err) {
44
+ daemonLog(`Failed to load projects directory: ${err}`);
45
+ }
46
+ }
47
+ saveProject(project) {
48
+ const filePath = join(this.projectsDir, `${project.id}.json`);
49
+ writeFileAtomic(filePath, JSON.stringify(project, null, 2));
50
+ this.projects.set(project.id, project);
51
+ }
52
+ createDefaultProject() {
53
+ const defaultProject = {
54
+ id: "default",
55
+ name: "Default",
56
+ color: "#ffffff",
57
+ createdAt: new Date().toISOString(),
58
+ isSystem: true,
59
+ isDefault: true,
60
+ };
61
+ this.saveProject(defaultProject);
62
+ }
63
+ getAll() {
64
+ return Array.from(this.projects.values());
65
+ }
66
+ get(id) {
67
+ return this.projects.get(id);
68
+ }
69
+ create(name, color) {
70
+ const id = crypto.randomBytes(4).toString("hex");
71
+ const project = {
72
+ id,
73
+ name,
74
+ color,
75
+ createdAt: new Date().toISOString(),
76
+ isSystem: false,
77
+ isDefault: false,
78
+ };
79
+ this.saveProject(project);
80
+ return project;
81
+ }
82
+ update(id, name, color) {
83
+ const project = this.projects.get(id);
84
+ if (!project)
85
+ throw new Error(`Project ${id} not found`);
86
+ if (project.isSystem)
87
+ throw new Error("Cannot rename system project");
88
+ project.name = name;
89
+ if (color)
90
+ project.color = color;
91
+ this.saveProject(project);
92
+ }
93
+ delete(id) {
94
+ const project = this.projects.get(id);
95
+ if (!project)
96
+ throw new Error(`Project ${id} not found`);
97
+ if (project.isSystem)
98
+ throw new Error("Cannot delete system project");
99
+ const filePath = join(this.projectsDir, `${id}.json`);
100
+ if (fs.existsSync(filePath))
101
+ fs.rmSync(filePath);
102
+ this.projects.delete(id);
103
+ }
104
+ }
@@ -6,14 +6,15 @@ import { LOG_TAIL_DEFAULT } from "../config/constants.js";
6
6
  import { send } from "../ipc/send.js";
7
7
  import { tail } from "../shared/tail.js";
8
8
  import { streamLogFollow } from "../ipc/handlers/logs-stream.js";
9
- import { splitLogByRuns } from "../core/log-parser.js";
10
9
  export class IpcServer {
11
10
  server;
12
11
  manager;
12
+ taskManager;
13
13
  socketPath;
14
14
  clients = new Set();
15
- constructor(manager) {
15
+ constructor(manager, taskManager) {
16
16
  this.manager = manager;
17
+ this.taskManager = taskManager;
17
18
  this.socketPath = getSocketPath();
18
19
  this.server = net.createServer((socket) => this.handleConnection(socket));
19
20
  }
@@ -107,7 +108,27 @@ export class IpcServer {
107
108
  this.respondOk(socket, this.manager.resume(request.payload.id), request.payload.id);
108
109
  break;
109
110
  }
111
+ case "stop-loop": {
112
+ this.respondOk(socket, this.manager.stopLoop(request.payload.id), request.payload.id);
113
+ break;
114
+ }
115
+ case "play-loop": {
116
+ if (this.manager.isMaxRunsBlocked(request.payload.id)) {
117
+ send(socket, { type: "error", message: t("errors.maxRunsReached") });
118
+ break;
119
+ }
120
+ this.respondOk(socket, this.manager.playLoop(request.payload.id), request.payload.id);
121
+ break;
122
+ }
110
123
  case "trigger": {
124
+ if (this.manager.isMaxRunsBlocked(request.payload.id)) {
125
+ send(socket, { type: "error", message: t("errors.maxRunsReached") });
126
+ break;
127
+ }
128
+ if (this.manager.isRunning(request.payload.id)) {
129
+ send(socket, { type: "error", message: t("errors.triggerWhileRunning") });
130
+ break;
131
+ }
111
132
  this.respondOk(socket, this.manager.trigger(request.payload.id), request.payload.id);
112
133
  break;
113
134
  }
@@ -120,11 +141,78 @@ export class IpcServer {
120
141
  this.handleRunLog(request, socket);
121
142
  break;
122
143
  }
144
+ case "run-log-stream": {
145
+ this.handleRunLogStream(request, socket);
146
+ break;
147
+ }
123
148
  case "attach":
124
149
  case "logs": {
125
150
  this.handleLogs(request, socket);
126
151
  break;
127
152
  }
153
+ case "task-create": {
154
+ const task = this.taskManager.create(request.payload);
155
+ send(socket, { type: "ok", data: task });
156
+ break;
157
+ }
158
+ case "task-update": {
159
+ const updated = this.taskManager.update(request.payload.id, request.payload);
160
+ this.respondOk(socket, updated !== null, request.payload.id, updated ?? undefined);
161
+ break;
162
+ }
163
+ case "task-list": {
164
+ send(socket, { type: "ok", data: this.taskManager.list() });
165
+ break;
166
+ }
167
+ case "task-get": {
168
+ const task = this.taskManager.get(request.payload.id);
169
+ this.respondOk(socket, task !== null, request.payload.id, task ?? undefined);
170
+ break;
171
+ }
172
+ case "task-delete": {
173
+ const ok = this.taskManager.delete(request.payload.id);
174
+ this.respondOk(socket, ok, request.payload.id);
175
+ break;
176
+ }
177
+ case "project-list": {
178
+ send(socket, { type: "ok", data: this.manager.listProjects() });
179
+ break;
180
+ }
181
+ case "project-create": {
182
+ const { name, color } = request.payload;
183
+ if (!name || !name.trim()) {
184
+ send(socket, { type: "error", message: t("project.error.nameRequired") });
185
+ break;
186
+ }
187
+ const project = this.manager.createProject(name.trim(), color);
188
+ send(socket, { type: "ok", data: project });
189
+ break;
190
+ }
191
+ case "project-update": {
192
+ const { id, name, color } = request.payload;
193
+ if (!name || !name.trim()) {
194
+ send(socket, { type: "error", message: t("project.error.nameEmpty") });
195
+ break;
196
+ }
197
+ try {
198
+ this.manager.updateProject(id, name.trim(), color);
199
+ send(socket, { type: "ok" });
200
+ }
201
+ catch (err) {
202
+ send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
203
+ }
204
+ break;
205
+ }
206
+ case "project-delete": {
207
+ try {
208
+ this.manager.deleteProject(request.payload.id);
209
+ send(socket, { type: "ok" });
210
+ }
211
+ catch (err) {
212
+ send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
213
+ }
214
+ break;
215
+ }
128
216
  case "shutdown": {
129
217
  send(socket, { type: "ok" });
130
218
  await this.manager.shutdown();
@@ -140,10 +228,93 @@ export class IpcServer {
140
228
  send(socket, { type: "ok", data: "" });
141
229
  return;
142
230
  }
143
- const content = fs.readFileSync(logPath, "utf-8");
144
- const runs = splitLogByRuns(content);
145
- const run = runs.find((r) => r.runNumber === runNumber);
146
- send(socket, { type: "ok", data: run ? run.lines.join("\n") : "" });
231
+ const meta = this.manager.status(id);
232
+ if (!meta) {
233
+ send(socket, { type: "ok", data: "" });
234
+ return;
235
+ }
236
+ const records = meta.runHistory
237
+ .filter((r) => r.runNumber === runNumber)
238
+ .sort((a, b) => a.logOffset - b.logOffset);
239
+ if (records.length === 0) {
240
+ send(socket, { type: "ok", data: "" });
241
+ return;
242
+ }
243
+ const buffer = fs.readFileSync(logPath);
244
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
245
+ const parts = records.map((record) => {
246
+ const start = record.logOffset;
247
+ const idx = allSorted.indexOf(record);
248
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : buffer.length;
249
+ return buffer.toString("utf-8", start, end);
250
+ });
251
+ send(socket, { type: "ok", data: parts.join("") });
252
+ }
253
+ handleRunLogStream(request, socket) {
254
+ const { id, runNumber } = request.payload;
255
+ const meta = this.manager.status(id);
256
+ if (!meta) {
257
+ send(socket, { type: "error", message: t("errors.loopNotFound", { id }) });
258
+ return;
259
+ }
260
+ const record = meta.runHistory.find((r) => r.runNumber === runNumber);
261
+ if (!record) {
262
+ send(socket, { type: "ok", data: "" });
263
+ return;
264
+ }
265
+ const logPath = this.manager.getLogPath(id);
266
+ if (!logPath || !fs.existsSync(logPath)) {
267
+ send(socket, { type: "ok", data: "" });
268
+ return;
269
+ }
270
+ const offset = record.logOffset;
271
+ const stat = fs.statSync(logPath);
272
+ if (stat.size > offset) {
273
+ const fd = fs.openSync(logPath, "r");
274
+ const buf = Buffer.alloc(stat.size - offset);
275
+ fs.readSync(fd, buf, 0, buf.length, offset);
276
+ fs.closeSync(fd);
277
+ for (const line of buf.toString().split("\n")) {
278
+ if (line) {
279
+ send(socket, { type: "data", line });
280
+ }
281
+ }
282
+ }
283
+ if (record.status === "completed") {
284
+ send(socket, { type: "end" });
285
+ return;
286
+ }
287
+ let fileSize = stat.size;
288
+ const watcher = fs.watch(logPath, (eventType) => {
289
+ if (eventType === "rename" && !fs.existsSync(logPath)) {
290
+ watcher.close();
291
+ send(socket, { type: "end" });
292
+ return;
293
+ }
294
+ if (eventType === "change") {
295
+ try {
296
+ const s = fs.statSync(logPath);
297
+ if (s.size > fileSize) {
298
+ const fd = fs.openSync(logPath, "r");
299
+ const buf = Buffer.alloc(s.size - fileSize);
300
+ fs.readSync(fd, buf, 0, buf.length, fileSize);
301
+ fs.closeSync(fd);
302
+ fileSize = s.size;
303
+ for (const line of buf.toString().split("\n")) {
304
+ if (line) {
305
+ send(socket, { type: "data", line });
306
+ }
307
+ }
308
+ }
309
+ }
310
+ catch {
311
+ watcher.close();
312
+ send(socket, { type: "end" });
313
+ }
314
+ }
315
+ });
316
+ socket.on("close", () => watcher.close());
317
+ socket.on("error", () => watcher.close());
147
318
  }
148
319
  handleLogs(request, socket) {
149
320
  const id = request.payload.id;
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { removeIfExists, writeFileAtomic } from "../shared/fs-utils.js";
6
- import { getLoopsDir, getLogsDir, loopFile, logFile, getPidFile, getSignatureFile, getSocketPath, } from "../config/paths.js";
6
+ import { getLoopsDir, getTasksDir, getLogsDir, loopFile, taskFile, logFile, getPidFile, getSignatureFile, getSocketPath, } from "../config/paths.js";
7
7
  export { getDataDir, getPidFile, getSocketPath } from "../config/paths.js";
8
8
  function ensureDirs() {
9
9
  fs.mkdirSync(getLoopsDir(), { recursive: true });
@@ -40,6 +40,37 @@ export function deleteLoop(id) {
40
40
  removeIfExists(loopFile(id));
41
41
  removeIfExists(logFile(id));
42
42
  }
43
+ export function saveTask(task) {
44
+ const dir = getTasksDir();
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ writeFileAtomic(taskFile(task.id), JSON.stringify(task, null, 2));
47
+ }
48
+ export function loadTask(id) {
49
+ const file = taskFile(id);
50
+ if (!fs.existsSync(file))
51
+ return null;
52
+ const raw = fs.readFileSync(file, "utf-8");
53
+ return JSON.parse(raw);
54
+ }
55
+ export function loadAllTasks() {
56
+ const dir = getTasksDir();
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
59
+ const tasks = [];
60
+ for (const file of files) {
61
+ try {
62
+ const raw = fs.readFileSync(path.join(dir, file), "utf-8");
63
+ tasks.push(JSON.parse(raw));
64
+ }
65
+ catch {
66
+ // skip corrupted files
67
+ }
68
+ }
69
+ return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
70
+ }
71
+ export function deleteTask(id) {
72
+ removeIfExists(taskFile(id));
73
+ }
43
74
  export function getLogPath(id) {
44
75
  ensureDirs();
45
76
  return logFile(id);
@@ -0,0 +1,58 @@
1
+ import crypto from "node:crypto";
2
+ import { saveTask, loadAllTasks, deleteTask as deleteTaskState } from "./state.js";
3
+ import { daemonLog } from "./daemon-log.js";
4
+ export class TaskManager {
5
+ tasks = new Map();
6
+ init() {
7
+ const saved = loadAllTasks();
8
+ for (const task of saved) {
9
+ this.tasks.set(task.id, task);
10
+ }
11
+ if (saved.length > 0) {
12
+ daemonLog(`loaded ${saved.length} task(s) from persisted state`);
13
+ }
14
+ }
15
+ create(input) {
16
+ const task = {
17
+ ...input,
18
+ createdAt: new Date().toISOString(),
19
+ };
20
+ this.tasks.set(task.id, task);
21
+ saveTask(task);
22
+ return task;
23
+ }
24
+ get(id) {
25
+ return this.tasks.get(id) ?? null;
26
+ }
27
+ list() {
28
+ return [...this.tasks.values()];
29
+ }
30
+ update(id, input) {
31
+ const existing = this.tasks.get(id);
32
+ if (!existing)
33
+ return null;
34
+ const updated = { ...existing, ...input };
35
+ this.tasks.set(id, updated);
36
+ saveTask(updated);
37
+ return updated;
38
+ }
39
+ delete(id) {
40
+ if (!this.tasks.has(id))
41
+ return false;
42
+ this.tasks.delete(id);
43
+ deleteTaskState(id);
44
+ return true;
45
+ }
46
+ createInline(command, commandArgs) {
47
+ const id = crypto.randomUUID().slice(0, 8);
48
+ const name = [command, ...commandArgs].join(" ").slice(0, 40);
49
+ return this.create({
50
+ id,
51
+ name,
52
+ command,
53
+ commandArgs,
54
+ onSuccessTaskId: null,
55
+ onFailureTaskId: null,
56
+ });
57
+ }
58
+ }