loop-task 1.4.7 → 1.5.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.
- package/README.md +115 -0
- package/dist/board/App.js +57 -18
- package/dist/board/components/ActionButtons.js +12 -12
- package/dist/board/components/ContextHelpModal.js +22 -0
- package/dist/board/components/CreateForm.js +27 -75
- package/dist/board/components/CreateProjectModal.js +8 -17
- package/dist/board/components/EditProjectModal.js +8 -17
- package/dist/board/components/ProjectsPage.js +43 -36
- package/dist/board/components/SearchSelect.js +99 -0
- package/dist/board/components/TaskForm.js +14 -38
- package/dist/board/focus-context.js +98 -0
- package/dist/board/hooks/useBoardKeybindings.js +5 -47
- package/dist/board/hooks/useTabNav.js +60 -0
- package/dist/board/hooks/useTaskKeybindings.js +8 -30
- package/dist/config/constants.js +2 -0
- package/dist/core/command-runner.js +33 -3
- package/dist/core/context-parser.js +55 -0
- package/dist/core/loop-controller.js +20 -2
- package/dist/core/template.js +3 -0
- package/dist/i18n/en.json +8 -1
- package/package.json +1 -1
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useKeyboard } from "@opentui/react";
|
|
2
|
-
import { nextTaskPanel } from "../components/TaskBrowser.js";
|
|
3
2
|
export function useTaskKeybindings(params) {
|
|
4
|
-
const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask,
|
|
3
|
+
const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, onToggleContextHelp, headerFocused = false, selectable = true, } = params;
|
|
5
4
|
const taskActions = selectable
|
|
6
5
|
? ["select", "edit", "delete"]
|
|
7
6
|
: ["edit", "delete"];
|
|
@@ -9,6 +8,8 @@ export function useTaskKeybindings(params) {
|
|
|
9
8
|
useKeyboard((key) => {
|
|
10
9
|
if (view !== "task-list")
|
|
11
10
|
return;
|
|
11
|
+
if (headerFocused)
|
|
12
|
+
return;
|
|
12
13
|
const name = key.name;
|
|
13
14
|
if (confirm)
|
|
14
15
|
return;
|
|
@@ -36,6 +37,11 @@ export function useTaskKeybindings(params) {
|
|
|
36
37
|
key.preventDefault();
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
40
|
+
if (name === "?") {
|
|
41
|
+
onToggleContextHelp();
|
|
42
|
+
key.preventDefault();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
39
45
|
if (name === "n") {
|
|
40
46
|
onCreateTask();
|
|
41
47
|
key.preventDefault();
|
|
@@ -46,34 +52,6 @@ export function useTaskKeybindings(params) {
|
|
|
46
52
|
key.preventDefault();
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
49
|
-
if (name === "tab") {
|
|
50
|
-
const direction = key.shift ? "left" : "right";
|
|
51
|
-
if (taskFocusedPanel === "actions") {
|
|
52
|
-
if (direction === "left" && taskSelectedAction === 0) {
|
|
53
|
-
setTaskFocusedPanel((p) => nextTaskPanel(p, "left"));
|
|
54
|
-
}
|
|
55
|
-
else if (direction === "right" && taskSelectedAction === taskActionCount - 1) {
|
|
56
|
-
onEnterHeader("right");
|
|
57
|
-
key.preventDefault();
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
setTaskSelectedAction((i) => direction === "right"
|
|
62
|
-
? Math.min(taskActionCount - 1, i + 1)
|
|
63
|
-
: Math.max(0, i - 1));
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
else if (taskFocusedPanel === "search" && direction === "left") {
|
|
67
|
-
onEnterHeader("left");
|
|
68
|
-
key.preventDefault();
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
setTaskFocusedPanel((p) => nextTaskPanel(p, direction));
|
|
73
|
-
}
|
|
74
|
-
key.preventDefault();
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
55
|
if (taskFocusedPanel === "tasks") {
|
|
78
56
|
if (name === "up" || name === "k") {
|
|
79
57
|
setTaskSelectedIndex((i) => Math.max(0, i - 1));
|
package/dist/config/constants.js
CHANGED
|
@@ -3,6 +3,7 @@ export const TOAST_TIMEOUT_MS = 3500;
|
|
|
3
3
|
export const TOAST_MAX = 4;
|
|
4
4
|
export const LOG_LINES_MAX = 500;
|
|
5
5
|
export const MAX_LOG_BYTES = 1024 * 1024;
|
|
6
|
+
export const MAX_CONTEXT_STDOUT_BYTES = 1024 * 1024;
|
|
6
7
|
export const MAX_LOG_GENERATIONS = 3;
|
|
7
8
|
export const SLEEP_CHUNK_MS = 200;
|
|
8
9
|
export const DAEMON_SPAWN_TIMEOUT_MS = 5000;
|
|
@@ -11,6 +12,7 @@ export const IPC_TIMEOUT_MS = 10000;
|
|
|
11
12
|
export const LOG_TAIL_DEFAULT = 50;
|
|
12
13
|
export const BOARD_BREAKPOINT_WIDTH = 80;
|
|
13
14
|
export const HEADER_COMPACT_WIDTH = 60;
|
|
15
|
+
export const SEARCH_SELECT_HEIGHT = 6;
|
|
14
16
|
export const HOVER_BG = "#1e3a5f";
|
|
15
17
|
export const PROJECT_COLORS = {
|
|
16
18
|
white: "#ffffff",
|
|
@@ -2,12 +2,13 @@ import fs from "node:fs";
|
|
|
2
2
|
import { execa } from "execa";
|
|
3
3
|
import { formatDuration } from "../duration.js";
|
|
4
4
|
import { t } from "../i18n/index.js";
|
|
5
|
+
import { MAX_CONTEXT_STDOUT_BYTES } from "../config/constants.js";
|
|
5
6
|
export function extractExitCode(error) {
|
|
6
7
|
return error && typeof error === "object" && "exitCode" in error
|
|
7
8
|
? error.exitCode
|
|
8
9
|
: 1;
|
|
9
10
|
}
|
|
10
|
-
export async function executeCommand(command, commandArgs, cwd, logStream, signal, runNumber) {
|
|
11
|
+
export async function executeCommand(command, commandArgs, cwd, logStream, signal, runNumber, captureStdout = false) {
|
|
11
12
|
const startedAt = new Date();
|
|
12
13
|
const header = t("loop.runHeader", { timestamp: startedAt.toLocaleString(), runNumber: runNumber ?? 0 });
|
|
13
14
|
logStream.write(header);
|
|
@@ -25,8 +26,25 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
25
26
|
cancelSignal: signal,
|
|
26
27
|
shell: true,
|
|
27
28
|
});
|
|
29
|
+
const stdoutChunks = [];
|
|
30
|
+
let stdoutTruncated = false;
|
|
31
|
+
let stdoutBytes = 0;
|
|
28
32
|
child.stdout.on("data", (chunk) => {
|
|
29
33
|
logStream.write(chunk);
|
|
34
|
+
if (captureStdout && !stdoutTruncated) {
|
|
35
|
+
const chunkStr = chunk.toString();
|
|
36
|
+
const chunkLen = Buffer.byteLength(chunkStr, "utf-8");
|
|
37
|
+
if (stdoutBytes + chunkLen > MAX_CONTEXT_STDOUT_BYTES) {
|
|
38
|
+
const remaining = MAX_CONTEXT_STDOUT_BYTES - stdoutBytes;
|
|
39
|
+
stdoutChunks.push(chunkStr.slice(0, remaining));
|
|
40
|
+
stdoutTruncated = true;
|
|
41
|
+
logStream.write(t("context.truncationWarning"));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
stdoutChunks.push(chunkStr);
|
|
45
|
+
stdoutBytes += chunkLen;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
30
48
|
});
|
|
31
49
|
child.stderr.on("data", (chunk) => {
|
|
32
50
|
logStream.write(chunk);
|
|
@@ -36,14 +54,26 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
36
54
|
const endedAt = new Date();
|
|
37
55
|
const duration = endedAt.getTime() - startedAt.getTime();
|
|
38
56
|
logStream.write(t("loop.exitMarker", { code: String(result.exitCode), duration: formatDuration(duration) }));
|
|
39
|
-
return {
|
|
57
|
+
return {
|
|
58
|
+
exitCode: result.exitCode ?? 0,
|
|
59
|
+
duration,
|
|
60
|
+
startedAt,
|
|
61
|
+
endedAt,
|
|
62
|
+
...(captureStdout ? { stdout: stdoutChunks.join("") } : {}),
|
|
63
|
+
};
|
|
40
64
|
}
|
|
41
65
|
catch (error) {
|
|
42
66
|
const endedAt = new Date();
|
|
43
67
|
const duration = endedAt.getTime() - startedAt.getTime();
|
|
44
68
|
const exitCode = extractExitCode(error);
|
|
45
69
|
logStream.write(t("loop.exitMarker", { code: exitCode, duration: formatDuration(duration) }));
|
|
46
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
exitCode,
|
|
72
|
+
duration,
|
|
73
|
+
startedAt,
|
|
74
|
+
endedAt,
|
|
75
|
+
...(captureStdout ? { stdout: stdoutChunks.join("") } : {}),
|
|
76
|
+
};
|
|
47
77
|
}
|
|
48
78
|
}
|
|
49
79
|
export async function executeCommandForeground(command, commandArgs, logger, cwd = "") {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
function isObject(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function parseStdout(raw) {
|
|
5
|
+
const trimmed = raw.trim();
|
|
6
|
+
if (trimmed.length === 0) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
let whole;
|
|
10
|
+
try {
|
|
11
|
+
whole = JSON.parse(trimmed);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
whole = undefined;
|
|
15
|
+
}
|
|
16
|
+
if (whole !== undefined) {
|
|
17
|
+
if (isObject(whole)) {
|
|
18
|
+
return whole;
|
|
19
|
+
}
|
|
20
|
+
if (typeof whole === "string" || typeof whole === "number" || typeof whole === "boolean" || whole === null) {
|
|
21
|
+
return { output: String(whole) };
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(whole)) {
|
|
24
|
+
return { output: trimmed };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const lines = trimmed.split("\n");
|
|
28
|
+
const parsedLines = [];
|
|
29
|
+
let jsonlSuccess = true;
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const l = line.trim();
|
|
32
|
+
if (l.length === 0)
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
parsedLines.push(JSON.parse(l));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
jsonlSuccess = false;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (jsonlSuccess && parsedLines.length > 0) {
|
|
43
|
+
const result = {};
|
|
44
|
+
for (const pl of parsedLines) {
|
|
45
|
+
if (isObject(pl)) {
|
|
46
|
+
Object.assign(result, pl);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
result.output = String(pl);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
return { output: trimmed };
|
|
55
|
+
}
|
|
@@ -7,6 +7,8 @@ import { executeCommand } from "./command-runner.js";
|
|
|
7
7
|
import { rotateLogIfNeeded } from "./log-rotator.js";
|
|
8
8
|
import { computePhase, alignToPhase } from "./scheduling.js";
|
|
9
9
|
import { t } from "../i18n/index.js";
|
|
10
|
+
import { parseStdout } from "./context-parser.js";
|
|
11
|
+
import { interpolate } from "./template.js";
|
|
10
12
|
export class LoopController extends EventEmitter {
|
|
11
13
|
abortController;
|
|
12
14
|
runAbortController = null;
|
|
@@ -323,8 +325,16 @@ export class LoopController extends EventEmitter {
|
|
|
323
325
|
const command = task?.command ?? this.options.command;
|
|
324
326
|
const commandArgs = task?.commandArgs ?? this.options.commandArgs;
|
|
325
327
|
const cwd = this.options.cwd;
|
|
326
|
-
const
|
|
328
|
+
const chainContext = {};
|
|
329
|
+
const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
|
|
330
|
+
const result = await executeCommand(command, commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount, hasChainTasks);
|
|
327
331
|
this.runAbortController = null;
|
|
332
|
+
if (hasChainTasks && result.stdout) {
|
|
333
|
+
const parsed = parseStdout(result.stdout);
|
|
334
|
+
if (parsed !== null) {
|
|
335
|
+
Object.assign(chainContext, parsed);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
328
338
|
this.lastExitCode = result.exitCode;
|
|
329
339
|
this.lastDuration = result.duration;
|
|
330
340
|
const logSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - this.currentRunStartOffset : 0;
|
|
@@ -373,7 +383,15 @@ export class LoopController extends EventEmitter {
|
|
|
373
383
|
chainGroupId,
|
|
374
384
|
chainName: chainTask.name,
|
|
375
385
|
});
|
|
376
|
-
const
|
|
386
|
+
const interpolatedCommand = interpolate(chainTask.command, chainContext);
|
|
387
|
+
const interpolatedArgs = chainTask.commandArgs.map(a => interpolate(a, chainContext));
|
|
388
|
+
const chainResult = await executeCommand(interpolatedCommand, interpolatedArgs, this.options.cwd, this.logStream, signal, this.runCount, true);
|
|
389
|
+
if (chainResult.stdout) {
|
|
390
|
+
const parsed = parseStdout(chainResult.stdout);
|
|
391
|
+
if (parsed !== null) {
|
|
392
|
+
Object.assign(chainContext, parsed);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
377
395
|
const chainLogSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - chainOffset : 0;
|
|
378
396
|
const chainRecord = this.runHistory.find((r) => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
|
|
379
397
|
if (chainRecord) {
|
package/dist/i18n/en.json
CHANGED
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
"board.idleLabel": " Stopped: ",
|
|
129
129
|
"board.searchTitle": " Search / ",
|
|
130
130
|
"board.searchPlaceholder": "type to filter, enter to apply",
|
|
131
|
+
"board.searchSelectPlaceholder": "type to filter...",
|
|
131
132
|
"board.searchEmpty": "press / to search",
|
|
132
133
|
"board.statusFilterTitle": " Status x ",
|
|
133
134
|
"board.sortTitle": " Order o ",
|
|
@@ -384,5 +385,11 @@
|
|
|
384
385
|
"project.error.deleteFailed": "Failed to delete project",
|
|
385
386
|
"project.toastCreated": "Project \"{name}\" created",
|
|
386
387
|
"project.toastUpdated": "Project \"{name}\" updated",
|
|
387
|
-
"project.toastDeleted": "Project \"{name}\" deleted"
|
|
388
|
+
"project.toastDeleted": "Project \"{name}\" deleted",
|
|
389
|
+
"context.truncationWarning": "stdout exceeded 1MB - context was truncated",
|
|
390
|
+
"context.helpTitle": "Chain Context Sharing",
|
|
391
|
+
"context.helpRules": "JSON output merges its keys. JSONL merges each line as a separate key. Plain text is stored under the \"output\" key. Empty output produces no change.",
|
|
392
|
+
"context.helpTemplates": "Use {{key}} in commands and args - replaced with context values from earlier tasks in the chain.",
|
|
393
|
+
"context.helpCaveat": "Plain text stdout overwrites the \"output\" key. Use JSON with named keys when data must survive across tasks.",
|
|
394
|
+
"context.helpJqTip": "Tip: use --jq '{key: .field}' to wrap any value in a named JSON key"
|
|
388
395
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loop-task",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|