loop-task 2.1.11 → 2.1.12
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/dist/cli.js +44 -0
- package/dist/client/commands.js +5 -1
- package/dist/core/context/validate-context.js +19 -0
- package/dist/core/loop/chain-executor.js +40 -14
- package/dist/core/loop/loop-controller.js +9 -0
- package/dist/core/loop/loop-runner.js +8 -0
- package/dist/core/loop/run-executor.js +1 -1
- package/dist/core/scheduling/index.js +4 -0
- package/dist/daemon/http/openapi.js +2 -2
- package/dist/daemon/http/route-loops.js +21 -0
- package/dist/daemon/http/route-tasks.js +17 -0
- package/dist/daemon/managers/loop-options.js +1 -0
- package/dist/daemon/managers/loop-serialization.js +1 -0
- package/dist/duration.js +5 -0
- package/dist/entities/loops/filters.js +2 -0
- package/dist/features/forms/FormRouter.js +2 -0
- package/dist/loop-config.js +7 -3
- package/dist/shared/i18n/en.json +8 -4
- package/dist/shared/ui/format.js +2 -0
- package/dist/widgets/loop-form/CreateForm.js +9 -2
- package/dist/widgets/loop-form/WizardForm.js +10 -0
- package/dist/widgets/loop-form/useCreateSteps.js +30 -2
- package/dist/widgets/loop-form/useHandleComplete.js +19 -2
- package/dist/widgets/task-form/TaskForm.js +49 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -79,12 +79,31 @@ program
|
|
|
79
79
|
.option("--project <name>", t("cli.optProject"))
|
|
80
80
|
.option("--offset <duration>", "Phase offset (e.g. 5m, 15m)")
|
|
81
81
|
.option("--description <desc>", "Description of the loop")
|
|
82
|
+
.option("-C, --context <json>", "Initial context JSON (e.g. {\"env\":\"staging\"})")
|
|
82
83
|
.action(async (intervalStr, cmdArgs, opts) => {
|
|
83
84
|
try {
|
|
84
85
|
const projectId = opts.project
|
|
85
86
|
? await resolveProjectId(opts.project)
|
|
86
87
|
: undefined;
|
|
87
88
|
const offsetMs = opts.offset ? parseDuration(opts.offset) : null;
|
|
89
|
+
let context;
|
|
90
|
+
if (opts.context) {
|
|
91
|
+
const { validateContext } = await import("./core/context/validate-context.js");
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(opts.context);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
console.error("Invalid JSON in --context flag");
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const result = validateContext(parsed);
|
|
101
|
+
if (!result.valid) {
|
|
102
|
+
console.error(`Invalid context: ${result.error}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
context = result.context;
|
|
106
|
+
}
|
|
88
107
|
const built = buildLoopOptions(intervalStr, {
|
|
89
108
|
...opts,
|
|
90
109
|
command: cmdArgs[0],
|
|
@@ -93,6 +112,7 @@ program
|
|
|
93
112
|
projectId,
|
|
94
113
|
offset: offsetMs,
|
|
95
114
|
description: opts.description ?? cmdArgs.join(" "),
|
|
115
|
+
context,
|
|
96
116
|
});
|
|
97
117
|
await startLoop(built.options, built.intervalHuman);
|
|
98
118
|
}
|
|
@@ -112,19 +132,43 @@ program
|
|
|
112
132
|
.option("--verbose", t("cli.optVerbose"))
|
|
113
133
|
.option("--cwd <dir>", t("cli.optCwd"))
|
|
114
134
|
.option("--description <desc>", "Description of the loop")
|
|
135
|
+
.option("-C, --context <json>", "Initial context JSON (e.g. {\"env\":\"staging\"})")
|
|
115
136
|
.action(async (intervalStr, cmdArgs, opts) => {
|
|
116
137
|
if (!intervalStr || !cmdArgs || cmdArgs.length === 0) {
|
|
117
138
|
program.help();
|
|
118
139
|
return;
|
|
119
140
|
}
|
|
120
141
|
const logger = new Logger(opts.verbose ?? false);
|
|
142
|
+
let context;
|
|
143
|
+
if (opts.context) {
|
|
144
|
+
const { validateContext } = await import("./core/context/validate-context.js");
|
|
145
|
+
let parsed;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(opts.context);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
console.error("Invalid JSON in --context flag");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
const result = validateContext(parsed);
|
|
154
|
+
if (!result.valid) {
|
|
155
|
+
console.error(`Invalid context: ${result.error}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
context = result.context;
|
|
159
|
+
}
|
|
121
160
|
const built = buildLoopOptions(intervalStr, {
|
|
122
161
|
...opts,
|
|
123
162
|
command: cmdArgs[0],
|
|
124
163
|
commandArgs: cmdArgs.slice(1),
|
|
125
164
|
cwd: opts.cwd ?? process.cwd(),
|
|
126
165
|
description: opts.description ?? cmdArgs.join(" "),
|
|
166
|
+
context,
|
|
127
167
|
});
|
|
168
|
+
if (built.options.interval === 0) {
|
|
169
|
+
console.error("Manual loops are not supported in foreground mode. Use 'loop-task new manual -- <command>' instead.");
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
128
172
|
const controller = new AbortController();
|
|
129
173
|
process.on("SIGINT", () => controller.abort());
|
|
130
174
|
process.on("SIGTERM", () => controller.abort());
|
package/dist/client/commands.js
CHANGED
|
@@ -23,6 +23,9 @@ export async function startLoop(options, intervalHuman) {
|
|
|
23
23
|
console.log(t("cli.startedCommand", { command: options.command }));
|
|
24
24
|
console.log(t("cli.startedInterval", { interval: intervalHuman }));
|
|
25
25
|
console.log(t("cli.startedStatus"));
|
|
26
|
+
if (options.interval === 0) {
|
|
27
|
+
console.log(" Note: manual loop — use 'trigger' to run on demand");
|
|
28
|
+
}
|
|
26
29
|
console.log();
|
|
27
30
|
console.log(t("cli.startedHint"));
|
|
28
31
|
process.exit(0);
|
|
@@ -73,7 +76,8 @@ export async function showStatus(id) {
|
|
|
73
76
|
const maxRuns = loop.maxRuns !== null ? String(loop.maxRuns) : t("cli.unlimited");
|
|
74
77
|
console.log(t("cli.statusTitle", { id: loop.id }));
|
|
75
78
|
console.log(t("cli.statusCommand", { command: cmd }));
|
|
76
|
-
|
|
79
|
+
const intervalDisplay = loop.interval === 0 ? "manual" : formatDuration(loop.interval);
|
|
80
|
+
console.log(t("cli.statusInterval", { interval: loop.intervalHuman, duration: intervalDisplay }));
|
|
77
81
|
console.log(t("cli.statusStatus", { status: loop.status }));
|
|
78
82
|
console.log(t("cli.statusRuns", { runs: loop.runCount, maxRuns }));
|
|
79
83
|
console.log(t("cli.statusCreated", { created: loop.createdAt }));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function validateContext(value) {
|
|
2
|
+
if (value === undefined || value === null) {
|
|
3
|
+
return { valid: true, context: {} };
|
|
4
|
+
}
|
|
5
|
+
if (typeof value !== "object" || Array.isArray(value) || value === null) {
|
|
6
|
+
return { valid: false, error: "Context must be a JSON object" };
|
|
7
|
+
}
|
|
8
|
+
const obj = value;
|
|
9
|
+
for (const key of Object.keys(obj)) {
|
|
10
|
+
const v = obj[key];
|
|
11
|
+
if (Array.isArray(v)) {
|
|
12
|
+
return { valid: false, error: `Context key "${key}" has an array — only string values are allowed` };
|
|
13
|
+
}
|
|
14
|
+
if (typeof v === "object" && v !== null) {
|
|
15
|
+
return { valid: false, error: `Context key "${key}" has a nested object — only string values are allowed` };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { valid: true, context: obj };
|
|
19
|
+
}
|
|
@@ -39,28 +39,54 @@ export function executeChain(options) {
|
|
|
39
39
|
chainGroupId,
|
|
40
40
|
chainName: chainTask.name,
|
|
41
41
|
});
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
const singleCommandFallback = {
|
|
43
|
+
command: chainTask.command,
|
|
44
|
+
commandArgs: chainTask.commandArgs,
|
|
45
|
+
commandRaw: chainTask.commandRaw,
|
|
46
|
+
};
|
|
47
|
+
const taskSteps = chainTask.steps?.length
|
|
48
|
+
? chainTask.steps
|
|
49
|
+
: [{ commands: [singleCommandFallback] }];
|
|
50
|
+
const chainTaskHasChains = !!(chainTask.onSuccessTaskId || chainTask.onFailureTaskId);
|
|
51
|
+
const shouldCaptureStdout = chainTaskHasChains || taskSteps.length > 1 || taskSteps[0].commands.length > 1;
|
|
52
|
+
let chainExitCode = 0;
|
|
53
|
+
let chainDuration = 0;
|
|
54
|
+
for (const step of taskSteps) {
|
|
55
|
+
const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, logStream, signal, runCount, shouldCaptureStdout)));
|
|
56
|
+
let stepStdout = "";
|
|
57
|
+
for (const r of stepResults) {
|
|
58
|
+
if (r.status === "fulfilled") {
|
|
59
|
+
chainDuration += r.value.duration;
|
|
60
|
+
if (r.value.stdout)
|
|
61
|
+
stepStdout += (stepStdout ? "\n" : "") + r.value.stdout;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (shouldCaptureStdout && stepStdout) {
|
|
65
|
+
const parsed = parseStdout(stepStdout);
|
|
66
|
+
if (parsed !== null) {
|
|
67
|
+
Object.assign(chainContext, parsed);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
|
|
71
|
+
if (stepFailure) {
|
|
72
|
+
const failed = stepResults.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
|
|
73
|
+
chainExitCode = failed ? failed.value.exitCode : 1;
|
|
74
|
+
break;
|
|
49
75
|
}
|
|
50
76
|
}
|
|
51
77
|
const chainLogSize = fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
|
|
52
78
|
const chainRecord = runHistory.find(r => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
|
|
53
79
|
if (chainRecord) {
|
|
54
|
-
chainRecord.exitCode =
|
|
55
|
-
chainRecord.duration =
|
|
80
|
+
chainRecord.exitCode = chainExitCode;
|
|
81
|
+
chainRecord.duration = chainDuration;
|
|
56
82
|
chainRecord.logSize = Math.max(0, chainLogSize);
|
|
57
83
|
chainRecord.status = "completed";
|
|
58
84
|
}
|
|
59
|
-
totalExtraDuration +=
|
|
60
|
-
finalExitCode =
|
|
61
|
-
currentTargetId = (
|
|
62
|
-
prevBranch =
|
|
63
|
-
prevExit =
|
|
85
|
+
totalExtraDuration += chainDuration;
|
|
86
|
+
finalExitCode = chainExitCode;
|
|
87
|
+
currentTargetId = (chainExitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
|
|
88
|
+
prevBranch = chainExitCode === 0 ? "onSuccess" : "onFailure";
|
|
89
|
+
prevExit = chainExitCode;
|
|
64
90
|
}
|
|
65
91
|
return { runHistory, lastExitCode: finalExitCode, lastDuration: totalExtraDuration };
|
|
66
92
|
})();
|
|
@@ -58,6 +58,13 @@ export class LoopController extends EventEmitter {
|
|
|
58
58
|
this.logStream?.end();
|
|
59
59
|
this.abortController = new AbortController();
|
|
60
60
|
this.logStream = fs.createWriteStream(this.logPath, { flags: "a" });
|
|
61
|
+
if (this.options.interval === 0 && !this._stopAfterRun) {
|
|
62
|
+
this._status = "idle";
|
|
63
|
+
this.nextRunAt = null;
|
|
64
|
+
this._loopActive = false;
|
|
65
|
+
this.emit("stopped");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
61
68
|
this.loopPromise = this.run().finally(() => { this._loopActive = false; });
|
|
62
69
|
if (this.sessionStartedAt === null) {
|
|
63
70
|
this.sessionStartedAt = new Date().toISOString();
|
|
@@ -112,6 +119,8 @@ export class LoopController extends EventEmitter {
|
|
|
112
119
|
this._maxRunsReached = true;
|
|
113
120
|
return false;
|
|
114
121
|
}
|
|
122
|
+
if (this.options.interval === 0)
|
|
123
|
+
return false;
|
|
115
124
|
this.sessionStartedAt = new Date().toISOString();
|
|
116
125
|
this._resetSchedule = true;
|
|
117
126
|
this._paused = false;
|
|
@@ -106,6 +106,14 @@ export async function runLoop(ctrl) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
else {
|
|
109
|
+
if (ctrl.options.interval === 0) {
|
|
110
|
+
ctrl._paused = true;
|
|
111
|
+
ctrl._status = "idle";
|
|
112
|
+
ctrl.remainingDelayMs = null;
|
|
113
|
+
ctrl.nextRunAt = null;
|
|
114
|
+
ctrl.emit("stopped");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
109
117
|
const nextSlotMs = runStartedAtMs + ctrl.options.interval;
|
|
110
118
|
const overrunMs = Date.now() - nextSlotMs;
|
|
111
119
|
if (overrunMs >= 0) {
|
|
@@ -28,7 +28,7 @@ export async function executeRunImpl(ctrl, signal) {
|
|
|
28
28
|
ctrl.runAbortController = new AbortController();
|
|
29
29
|
const task = ctrl.options.taskId ? ctrl.taskResolver(ctrl.options.taskId) : null;
|
|
30
30
|
const cwd = resolveEffectiveCwd(ctrl.options.cwd, ctrl.projectDirectory);
|
|
31
|
-
const chainContext = {};
|
|
31
|
+
const chainContext = { ...(task?.context ?? {}), ...(ctrl.options.context ?? {}) };
|
|
32
32
|
const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
|
|
33
33
|
const singleCommandFallback = {
|
|
34
34
|
command: task?.command ?? ctrl.options.command,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export function computePhase(loopId, intervalMs) {
|
|
2
|
+
if (intervalMs <= 0)
|
|
3
|
+
return 0;
|
|
2
4
|
let hash = 0;
|
|
3
5
|
for (let i = 0; i < loopId.length; i++) {
|
|
4
6
|
hash = ((hash << 5) - hash + loopId.charCodeAt(i)) | 0;
|
|
@@ -6,6 +8,8 @@ export function computePhase(loopId, intervalMs) {
|
|
|
6
8
|
return Math.abs(hash) % intervalMs;
|
|
7
9
|
}
|
|
8
10
|
export function alignToPhase(now, intervalMs, phaseMs) {
|
|
11
|
+
if (intervalMs <= 0)
|
|
12
|
+
return 0;
|
|
9
13
|
const elapsed = now % intervalMs;
|
|
10
14
|
const delay = (phaseMs - elapsed + intervalMs) % intervalMs;
|
|
11
15
|
return delay;
|
|
@@ -13,7 +13,7 @@ export function buildOpenApiSpec() {
|
|
|
13
13
|
paths: {
|
|
14
14
|
"/api/loops": {
|
|
15
15
|
get: { summary: "List all loops", tags: ["Loops"], responses: { "200": { description: "Array of loops", content: { "application/json": { schema: { type: "array" } } } } } },
|
|
16
|
-
post: { summary: "Create a new loop", tags: ["Loops"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, intervalHuman: { type: "string", example: "5m" }, cwd: { type: "string" }, description: { type: "string" }, taskId: { type: "string" }, now: { type: "boolean" }, maxRuns: { type: "integer" }, verbose: { type: "boolean" }, projectId: { type: "string" }, offset: { type: "integer" } } } } } }, responses: { "201": { description: "Loop created", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } }, "400": { description: "Validation error" } } },
|
|
16
|
+
post: { summary: "Create a new loop", tags: ["Loops"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, intervalHuman: { type: "string", example: "5m", description: "Interval like 30s, 5m, 1h, or 'manual' for trigger-only loops" }, cwd: { type: "string" }, description: { type: "string" }, taskId: { type: "string" }, now: { type: "boolean" }, maxRuns: { type: "integer" }, verbose: { type: "boolean" }, projectId: { type: "string" }, offset: { type: "integer" }, context: { type: "object", additionalProperties: true, description: "Initial context for chain interpolation" } } } } } }, responses: { "201": { description: "Loop created", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } }, "400": { description: "Validation error" } } },
|
|
17
17
|
},
|
|
18
18
|
"/api/loops/{id}": {
|
|
19
19
|
get: { summary: "Get loop status", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Loop details" }, "404": { description: "Not found" } } },
|
|
@@ -30,7 +30,7 @@ export function buildOpenApiSpec() {
|
|
|
30
30
|
"/api/loops/{id}/runs/{num}": { get: { summary: "Get run-specific log", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "num", in: "path", required: true, schema: { type: "integer" } }], responses: { "200": { description: "Run log content" } } } },
|
|
31
31
|
"/api/tasks": {
|
|
32
32
|
get: { summary: "List all tasks", tags: ["Tasks"], responses: { "200": { description: "Array of tasks" } } },
|
|
33
|
-
post: { summary: "Create a task", tags: ["Tasks"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" } } } } } }, responses: { "201": { description: "Task created" }, "400": { description: "Validation error" } } },
|
|
33
|
+
post: { summary: "Create a task", tags: ["Tasks"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" }, context: { type: "object", additionalProperties: true, description: "Initial context for chain interpolation" } } } } } }, responses: { "201": { description: "Task created" }, "400": { description: "Validation error" } } },
|
|
34
34
|
},
|
|
35
35
|
"/api/tasks/{id}": {
|
|
36
36
|
get: { summary: "Get a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Task details" }, "404": { description: "Not found" } } },
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import { LOG_TAIL_DEFAULT } from "../../shared/config/constants.js";
|
|
3
3
|
import { tail } from "../../shared/tail.js";
|
|
4
4
|
import { buildLoopOptions } from "../../loop-config.js";
|
|
5
|
+
import { validateContext } from "../../core/context/validate-context.js";
|
|
5
6
|
import { sendOk, sendError, sendNotFound, parseQuery, readBody } from "./helpers.js";
|
|
6
7
|
export function registerLoopRoutes(manager, routes, r) {
|
|
7
8
|
r("GET", "/api/loops", (_req, res) => {
|
|
@@ -18,6 +19,15 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
18
19
|
r("POST", "/api/loops", async (req, res) => {
|
|
19
20
|
try {
|
|
20
21
|
const body = await readBody(req);
|
|
22
|
+
let context;
|
|
23
|
+
if (body.context !== undefined) {
|
|
24
|
+
const result = validateContext(body.context);
|
|
25
|
+
if (!result.valid) {
|
|
26
|
+
sendError(res, 400, result.error);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
context = result.context;
|
|
30
|
+
}
|
|
21
31
|
const intervalHuman = body.intervalHuman ?? "5m";
|
|
22
32
|
const { options } = buildLoopOptions(intervalHuman, {
|
|
23
33
|
command: body.command,
|
|
@@ -30,6 +40,7 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
30
40
|
description: body.description,
|
|
31
41
|
projectId: body.projectId,
|
|
32
42
|
offset: body.offset,
|
|
43
|
+
context,
|
|
33
44
|
});
|
|
34
45
|
const id = manager.start(options, intervalHuman);
|
|
35
46
|
sendOk(res, { id }, 201);
|
|
@@ -41,6 +52,15 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
41
52
|
r("PATCH", "/api/loops/:id", async (req, res, params) => {
|
|
42
53
|
try {
|
|
43
54
|
const body = await readBody(req);
|
|
55
|
+
let context;
|
|
56
|
+
if (body.context !== undefined) {
|
|
57
|
+
const result = validateContext(body.context);
|
|
58
|
+
if (!result.valid) {
|
|
59
|
+
sendError(res, 400, result.error);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
context = result.context;
|
|
63
|
+
}
|
|
44
64
|
const intervalHuman = body.intervalHuman ?? "5m";
|
|
45
65
|
const { options } = buildLoopOptions(intervalHuman, {
|
|
46
66
|
command: body.command,
|
|
@@ -53,6 +73,7 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
53
73
|
description: body.description,
|
|
54
74
|
projectId: body.projectId,
|
|
55
75
|
offset: body.offset,
|
|
76
|
+
context,
|
|
56
77
|
});
|
|
57
78
|
const ok = await manager.update(params.id, options, intervalHuman);
|
|
58
79
|
if (!ok) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateContext } from "../../core/context/validate-context.js";
|
|
1
2
|
import { sendOk, sendError, sendNotFound, readBody } from "./helpers.js";
|
|
2
3
|
export function registerTaskRoutes(taskManager, r) {
|
|
3
4
|
r("GET", "/api/tasks", (_req, res) => {
|
|
@@ -22,6 +23,14 @@ export function registerTaskRoutes(taskManager, r) {
|
|
|
22
23
|
sendError(res, 400, "Task command is required");
|
|
23
24
|
return;
|
|
24
25
|
}
|
|
26
|
+
if (body.context !== undefined) {
|
|
27
|
+
const result = validateContext(body.context);
|
|
28
|
+
if (!result.valid) {
|
|
29
|
+
sendError(res, 400, result.error);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
body.context = result.context;
|
|
33
|
+
}
|
|
25
34
|
const task = taskManager.create(body);
|
|
26
35
|
sendOk(res, task, 201);
|
|
27
36
|
}
|
|
@@ -32,6 +41,14 @@ export function registerTaskRoutes(taskManager, r) {
|
|
|
32
41
|
r("PATCH", "/api/tasks/:id", async (req, res, params) => {
|
|
33
42
|
try {
|
|
34
43
|
const body = await readBody(req);
|
|
44
|
+
if (body.context !== undefined) {
|
|
45
|
+
const result = validateContext(body.context);
|
|
46
|
+
if (!result.valid) {
|
|
47
|
+
sendError(res, 400, result.error);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
body.context = result.context;
|
|
51
|
+
}
|
|
35
52
|
const updated = taskManager.update(params.id, body);
|
|
36
53
|
if (!updated) {
|
|
37
54
|
sendNotFound(res, params.id);
|
|
@@ -27,6 +27,7 @@ export function toMeta(controller, options, intervalHuman, taskManager) {
|
|
|
27
27
|
pid: process.pid,
|
|
28
28
|
projectId: options.projectId ?? "default",
|
|
29
29
|
offset: options.offset,
|
|
30
|
+
context: options.context,
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
export function persistLoop(id, controller, options, intervalHuman, taskManager, lastSerialized) {
|
package/dist/duration.js
CHANGED
|
@@ -5,6 +5,9 @@ export function parseDuration(input) {
|
|
|
5
5
|
if (!trimmed) {
|
|
6
6
|
throw new Error(t("errors.durationEmpty"));
|
|
7
7
|
}
|
|
8
|
+
if (trimmed === "manual" || trimmed === "0") {
|
|
9
|
+
return 0;
|
|
10
|
+
}
|
|
8
11
|
const result = ms(trimmed);
|
|
9
12
|
if (typeof result !== "number" || isNaN(result)) {
|
|
10
13
|
throw new Error(t("errors.durationInvalid", { input }));
|
|
@@ -15,5 +18,7 @@ export function parseDuration(input) {
|
|
|
15
18
|
return result;
|
|
16
19
|
}
|
|
17
20
|
export function formatDuration(value) {
|
|
21
|
+
if (value === 0)
|
|
22
|
+
return t("format.durationManual");
|
|
18
23
|
return ms(value, { long: true });
|
|
19
24
|
}
|
|
@@ -15,6 +15,7 @@ function createInitialValues(editTarget, currentProjectId) {
|
|
|
15
15
|
runNow: "y",
|
|
16
16
|
maxRuns: "",
|
|
17
17
|
project: currentProjectId,
|
|
18
|
+
context: "",
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
21
|
return {
|
|
@@ -27,6 +28,7 @@ function createInitialValues(editTarget, currentProjectId) {
|
|
|
27
28
|
runNow: "y",
|
|
28
29
|
maxRuns: editTarget.maxRuns?.toString() ?? "",
|
|
29
30
|
project: editTarget.projectId ?? "default",
|
|
31
|
+
context: editTarget.context ? JSON.stringify(editTarget.context) : "",
|
|
30
32
|
};
|
|
31
33
|
}
|
|
32
34
|
export function FormRouter(props) {
|
package/dist/loop-config.js
CHANGED
|
@@ -129,20 +129,24 @@ export function buildLoopOptions(intervalHuman, input = {}) {
|
|
|
129
129
|
if (!description) {
|
|
130
130
|
throw new Error(t("errors.descriptionEmpty"));
|
|
131
131
|
}
|
|
132
|
+
const parsedInterval = parseDuration(intervalHuman);
|
|
133
|
+
const isManual = parsedInterval === 0;
|
|
134
|
+
const normalizedIntervalHuman = isManual ? "manual" : intervalHuman;
|
|
132
135
|
return {
|
|
133
|
-
intervalHuman,
|
|
136
|
+
intervalHuman: normalizedIntervalHuman,
|
|
134
137
|
options: {
|
|
135
|
-
interval:
|
|
138
|
+
interval: parsedInterval,
|
|
136
139
|
taskId,
|
|
137
140
|
command,
|
|
138
141
|
commandArgs,
|
|
139
142
|
cwd: input.cwd ?? "",
|
|
140
|
-
immediate: input.now ?? false,
|
|
143
|
+
immediate: isManual ? false : (input.now ?? false),
|
|
141
144
|
maxRuns: parseMaxRuns(input.maxRuns),
|
|
142
145
|
verbose: input.verbose ?? false,
|
|
143
146
|
description,
|
|
144
147
|
projectId: input.projectId ?? "default",
|
|
145
148
|
offset: input.offset ?? null,
|
|
149
|
+
context: input.context,
|
|
146
150
|
},
|
|
147
151
|
};
|
|
148
152
|
}
|
package/dist/shared/i18n/en.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"cli.startDescription": "Start the background daemon (restores persisted loops)",
|
|
5
5
|
"cli.newDescription": "Create a new loop in the background",
|
|
6
6
|
"cli.runDescription": "Run a loop in the foreground",
|
|
7
|
-
"cli.argInterval": "Interval between runs (e.g. 30s, 5m, 1h)",
|
|
7
|
+
"cli.argInterval": "Interval between runs (e.g. 30s, 5m, 1h, or manual)",
|
|
8
8
|
"cli.argCommand": "Command to execute",
|
|
9
9
|
"cli.optNow": "Run immediately before waiting",
|
|
10
10
|
"cli.optMaxRuns": "Stop after N executions",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"loop.debugDuration": "Duration: {duration}",
|
|
84
84
|
"loop.nextRun": "Next run in {duration} (at {time})",
|
|
85
85
|
"errors.durationEmpty": "Duration cannot be empty",
|
|
86
|
-
"errors.durationInvalid": "Invalid duration: \"{input}\". Use formats like 10s, 5m, 1h, 1d, 1w",
|
|
86
|
+
"errors.durationInvalid": "Invalid duration: \"{input}\". Use formats like 10s, 5m, 1h, 1d, 1w, or manual",
|
|
87
87
|
"errors.durationNotPositive": "Duration must be positive, got: \"{input}\"",
|
|
88
88
|
"errors.maxRunsInvalid": "--max-runs must be a positive integer",
|
|
89
89
|
"errors.unbalancedQuote": "Unbalanced quote in command",
|
|
@@ -117,6 +117,7 @@
|
|
|
117
117
|
"format.daysAhead": "{days}d",
|
|
118
118
|
"format.timingPaused": "paused",
|
|
119
119
|
"format.timingIdle": "not scheduled",
|
|
120
|
+
"format.durationManual": "manual trigger only",
|
|
120
121
|
"format.timingNext": "in {timeAgo}",
|
|
121
122
|
"format.timingLast": "last {timeAgo}",
|
|
122
123
|
"format.timingNew": "new",
|
|
@@ -257,7 +258,7 @@
|
|
|
257
258
|
"board.taskCreateTitle": " New Task ",
|
|
258
259
|
"board.taskEditTitle": " Edit Task ",
|
|
259
260
|
"board.hintTaskMode": "type a command inline, or pick an existing task",
|
|
260
|
-
"board.hintInterval": "How often to run, e.g. 30s, 5m, 30m, 1h, 1d",
|
|
261
|
+
"board.hintInterval": "How often to run, e.g. 30s, 5m, 30m, 1h, 1d, or manual",
|
|
261
262
|
"board.hintCommand": "Full command line. Quote args with spaces",
|
|
262
263
|
"board.hintDescription": "Short label shown in the list. Blank uses the command",
|
|
263
264
|
"board.hintCwd": "Leave blank to inherit from project directory, or set a specific path",
|
|
@@ -574,7 +575,7 @@
|
|
|
574
575
|
"wizard.newTask": "New task",
|
|
575
576
|
"wizard.stepOf": "Step {current} of {total}",
|
|
576
577
|
"wizard.intervalPrompt": "How often should it run?",
|
|
577
|
-
"wizard.intervalHint": "e.g. 30s, 5m, 30m, 1h, 1d",
|
|
578
|
+
"wizard.intervalHint": "e.g. 30s, 5m, 30m, 1h, 1d, or manual for trigger-only",
|
|
578
579
|
"wizard.commandPrompt": "What command should run?",
|
|
579
580
|
"wizard.commandHint": "Full command line. Quote args with spaces",
|
|
580
581
|
"wizard.taskModePrompt": "Inline command or existing task?",
|
|
@@ -596,6 +597,9 @@
|
|
|
596
597
|
"wizard.taskCommandPrompt": "What command should this task run?",
|
|
597
598
|
"wizard.onSuccessPrompt": "On success, chain to? (optional)",
|
|
598
599
|
"wizard.onFailurePrompt": "On failure, chain to? (optional)",
|
|
600
|
+
"wizard.contextPrompt": "Initial context? (optional)",
|
|
601
|
+
"wizard.contextHint": "JSON object with string values, e.g. {\"env\":\"staging\"}",
|
|
602
|
+
"wizard.contextInvalid": "Invalid context: must be a flat JSON object with non-nested, non-array values",
|
|
599
603
|
"wizard.created": "Created",
|
|
600
604
|
"wizard.filled": "{filled}/{total} filled",
|
|
601
605
|
"wizard.footerHints": "tab next . ctrl+s save . esc cancel",
|
package/dist/shared/ui/format.js
CHANGED
|
@@ -66,6 +66,8 @@ export function statusColor(status) {
|
|
|
66
66
|
return STATUS_COLORS[status] ?? "#ffffff";
|
|
67
67
|
}
|
|
68
68
|
export function timingLabel(loop) {
|
|
69
|
+
if (loop.interval === 0)
|
|
70
|
+
return t("format.durationManual");
|
|
69
71
|
if (loop.status === "paused")
|
|
70
72
|
return t("format.timingPaused");
|
|
71
73
|
if (loop.status === "idle")
|
|
@@ -15,7 +15,9 @@ export function CreateView(props) {
|
|
|
15
15
|
const [taskPickerOpen, setTaskPickerOpen] = useState(false);
|
|
16
16
|
const [openSelect, setOpenSelect] = useState(null);
|
|
17
17
|
const [commandEditorOpen, setCommandEditorOpen] = useState(false);
|
|
18
|
+
const [contextEditorOpen, setContextEditorOpen] = useState(false);
|
|
18
19
|
const [commandValue, setCommandValue] = useState(initial.command ?? "");
|
|
20
|
+
const [contextValue, setContextValue] = useState(initial.context ?? "");
|
|
19
21
|
const fieldCallbacksRef = useRef({});
|
|
20
22
|
const taskModeInitial = initial.taskMode === "existing" ? "Existing task" : "Inline command";
|
|
21
23
|
const resolvedTaskName = useMemo(() => {
|
|
@@ -32,10 +34,12 @@ export function CreateView(props) {
|
|
|
32
34
|
selectedTaskId,
|
|
33
35
|
resolvedTaskName,
|
|
34
36
|
commandValue,
|
|
37
|
+
contextValue,
|
|
35
38
|
projects: props.projects,
|
|
36
39
|
setOpenSelect,
|
|
37
40
|
setTaskPickerOpen,
|
|
38
41
|
setCommandEditorOpen,
|
|
42
|
+
setContextEditorOpen,
|
|
39
43
|
fieldCallbacksRef,
|
|
40
44
|
taskModeInitial,
|
|
41
45
|
});
|
|
@@ -61,7 +65,7 @@ export function CreateView(props) {
|
|
|
61
65
|
const selectTitleFor = (field) => field === "taskMode" ? t("wizard.taskModePrompt")
|
|
62
66
|
: field === "runNow" ? t("wizard.runNowPrompt")
|
|
63
67
|
: t("wizard.projectPrompt");
|
|
64
|
-
return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: mode === "edit" ? t("wizard.editLoop") : t("wizard.newLoop"), steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: taskPickerOpen || openSelect !== null || commandEditorOpen }), taskPickerOpen ? (_jsx(TaskPickerModal, { tasks: tasks, onSelect: (task) => {
|
|
68
|
+
return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: mode === "edit" ? t("wizard.editLoop") : t("wizard.newLoop"), steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: taskPickerOpen || openSelect !== null || commandEditorOpen || contextEditorOpen }), taskPickerOpen ? (_jsx(TaskPickerModal, { tasks: tasks, onSelect: (task) => {
|
|
65
69
|
onChooseTask({ id: task.id, name: task.name });
|
|
66
70
|
setTaskPickerOpen(false);
|
|
67
71
|
}, onClose: () => setTaskPickerOpen(false) })) : null, openSelect ? (_jsx(SelectModal, { title: selectTitleFor(openSelect), options: selectOptionsFor(openSelect), initialValue: fieldCallbacksRef.current[openSelect]?.value, onSelect: (option) => {
|
|
@@ -71,5 +75,8 @@ export function CreateView(props) {
|
|
|
71
75
|
}, onClose: () => setOpenSelect(null) })) : null, commandEditorOpen ? (_jsx(CodeEditorModal, { initialValue: commandValue, onSave: (v) => {
|
|
72
76
|
setCommandValue(v);
|
|
73
77
|
setCommandEditorOpen(false);
|
|
74
|
-
}, onCancel: () => setCommandEditorOpen(false) })) : null
|
|
78
|
+
}, onCancel: () => setCommandEditorOpen(false) })) : null, contextEditorOpen ? (_jsx(CodeEditorModal, { initialValue: contextValue, onSave: (v) => {
|
|
79
|
+
setContextValue(v);
|
|
80
|
+
setContextEditorOpen(false);
|
|
81
|
+
}, onCancel: () => setContextEditorOpen(false) })) : null] }));
|
|
75
82
|
}
|
|
@@ -59,6 +59,9 @@ export function WizardForm(props) {
|
|
|
59
59
|
const err = validateField(s.key, resolvedValues);
|
|
60
60
|
if (err)
|
|
61
61
|
errors[s.key] = err;
|
|
62
|
+
if (s.validate && !s.validate(valueFor(s))) {
|
|
63
|
+
errors[s.key] = s.validationError ?? t("wizard.validationError");
|
|
64
|
+
}
|
|
62
65
|
}
|
|
63
66
|
if (Object.keys(errors).length > 0) {
|
|
64
67
|
setValidationErrors(errors);
|
|
@@ -99,6 +102,13 @@ export function WizardForm(props) {
|
|
|
99
102
|
setError(step.key, err);
|
|
100
103
|
return;
|
|
101
104
|
}
|
|
105
|
+
if (step.validate) {
|
|
106
|
+
const value = valueFor(step);
|
|
107
|
+
if (!step.validate(value)) {
|
|
108
|
+
setError(step.key, step.validationError ?? t("wizard.validationError"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
102
112
|
clearError(step.key);
|
|
103
113
|
}
|
|
104
114
|
const next = findNextField(activeField, delta);
|
|
@@ -3,8 +3,9 @@ import { useMemo } from "react";
|
|
|
3
3
|
import { t } from "../../shared/i18n/index.js";
|
|
4
4
|
import { SelectValueField } from "../../shared/ui/SelectModal.js";
|
|
5
5
|
import { CodeEditorPreview } from "../../features/code-editor/CodeEditorPreview.js";
|
|
6
|
+
import { validateContext } from "../../core/context/validate-context.js";
|
|
6
7
|
export function useCreateSteps(params) {
|
|
7
|
-
const { initial, selectedTaskId, resolvedTaskName, commandValue, projects, setOpenSelect, setTaskPickerOpen, setCommandEditorOpen, fieldCallbacksRef, taskModeInitial, } = params;
|
|
8
|
+
const { initial, selectedTaskId, resolvedTaskName, commandValue, contextValue, projects, setOpenSelect, setTaskPickerOpen, setCommandEditorOpen, setContextEditorOpen, fieldCallbacksRef, taskModeInitial, } = params;
|
|
8
9
|
return useMemo(() => {
|
|
9
10
|
const list = [
|
|
10
11
|
{
|
|
@@ -59,6 +60,10 @@ export function useCreateSteps(params) {
|
|
|
59
60
|
defaultValue: initial.runNow === "true" || initial.runNow === "yes"
|
|
60
61
|
? t("wizard.runNowNow")
|
|
61
62
|
: t("wizard.runNowWait"),
|
|
63
|
+
skip: (values) => {
|
|
64
|
+
const v = (values.interval ?? "").trim().toLowerCase();
|
|
65
|
+
return v === "manual" || v === "0";
|
|
66
|
+
},
|
|
62
67
|
onActivate: () => setOpenSelect("runNow"),
|
|
63
68
|
renderCustom: ({ value, isActive, onChange, onAdvance }) => {
|
|
64
69
|
fieldCallbacksRef.current.runNow = { value, onChange, onAdvance };
|
|
@@ -101,7 +106,30 @@ export function useCreateSteps(params) {
|
|
|
101
106
|
return (_jsx(SelectValueField, { label: value || null, placeholder: t("wizard.projectPrompt"), isActive: isActive }));
|
|
102
107
|
},
|
|
103
108
|
},
|
|
109
|
+
{
|
|
110
|
+
key: "context",
|
|
111
|
+
prompt: t("wizard.contextPrompt"),
|
|
112
|
+
hint: t("wizard.contextHint"),
|
|
113
|
+
required: false,
|
|
114
|
+
inputType: "text",
|
|
115
|
+
defaultValue: contextValue || undefined,
|
|
116
|
+
onActivate: () => setContextEditorOpen(true),
|
|
117
|
+
validate: (value) => {
|
|
118
|
+
if (!value?.trim())
|
|
119
|
+
return true;
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(value);
|
|
122
|
+
const result = validateContext(parsed);
|
|
123
|
+
return result.valid;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
validationError: t("wizard.contextInvalid"),
|
|
130
|
+
renderCustom: ({ isActive }) => (_jsx(CodeEditorPreview, { value: contextValue, hint: t("wizard.contextHint"), isActive: isActive, onActivate: () => setContextEditorOpen(true) })),
|
|
131
|
+
},
|
|
104
132
|
];
|
|
105
133
|
return list;
|
|
106
|
-
}, [taskModeInitial, selectedTaskId, resolvedTaskName, initial, commandValue, projects]);
|
|
134
|
+
}, [taskModeInitial, selectedTaskId, resolvedTaskName, initial, commandValue, contextValue, projects]);
|
|
107
135
|
}
|
|
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
|
|
2
2
|
import { t } from "../../shared/i18n/index.js";
|
|
3
3
|
import { parseDuration } from "../../duration.js";
|
|
4
4
|
import { parseCommandLine, joinCommandLines } from "../../loop-config.js";
|
|
5
|
+
import { validateContext } from "../../core/context/validate-context.js";
|
|
5
6
|
export function useHandleComplete(params) {
|
|
6
7
|
const { selectedTaskId, mode, editId, currentProjectId, onDone, commandValue, projects, loopService, } = params;
|
|
7
8
|
return useCallback((values) => {
|
|
@@ -15,7 +16,8 @@ export function useHandleComplete(params) {
|
|
|
15
16
|
catch {
|
|
16
17
|
return;
|
|
17
18
|
}
|
|
18
|
-
const
|
|
19
|
+
const isManual = interval === 0;
|
|
20
|
+
const intervalHuman = isManual ? "manual" : intervalInput.trim();
|
|
19
21
|
const isExistingTask = !!values.taskMode?.includes("Existing");
|
|
20
22
|
if (isExistingTask && !selectedTaskId && !values.taskId?.trim())
|
|
21
23
|
return;
|
|
@@ -41,6 +43,20 @@ export function useHandleComplete(params) {
|
|
|
41
43
|
const projectName = values.project ?? "";
|
|
42
44
|
const project = projects.find((p) => p.name === projectName);
|
|
43
45
|
const projectId = project?.id ?? currentProjectId;
|
|
46
|
+
let context;
|
|
47
|
+
const contextStr = (values.context ?? "").trim();
|
|
48
|
+
if (contextStr) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(contextStr);
|
|
51
|
+
const result = validateContext(parsed);
|
|
52
|
+
if (result.valid) {
|
|
53
|
+
context = result.context;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// invalid context, skip
|
|
58
|
+
}
|
|
59
|
+
}
|
|
44
60
|
const options = {
|
|
45
61
|
interval,
|
|
46
62
|
taskId: isExistingTask
|
|
@@ -50,7 +66,7 @@ export function useHandleComplete(params) {
|
|
|
50
66
|
commandArgs: args,
|
|
51
67
|
commandRaw: isExistingTask ? undefined : cmdValue,
|
|
52
68
|
cwd: (values.cwd ?? "").trim() || process.cwd(),
|
|
53
|
-
immediate: runNowValue,
|
|
69
|
+
immediate: isManual ? false : runNowValue,
|
|
54
70
|
maxRuns: (values.maxRuns ?? "").trim()
|
|
55
71
|
? parseInt(values.maxRuns, 10)
|
|
56
72
|
: null,
|
|
@@ -58,6 +74,7 @@ export function useHandleComplete(params) {
|
|
|
58
74
|
description: (values.description ?? "").trim(),
|
|
59
75
|
projectId,
|
|
60
76
|
offset: null,
|
|
77
|
+
context,
|
|
61
78
|
};
|
|
62
79
|
const desc = (values.description ?? "").trim() || [cmdOnly, ...args].join(" ").trim();
|
|
63
80
|
if (mode === "edit" && editId) {
|
|
@@ -6,6 +6,7 @@ import { CodeEditorPreview } from "../../features/code-editor/CodeEditorPreview.
|
|
|
6
6
|
import { CodeEditorModal } from "../../features/code-editor/CodeEditorModal.js";
|
|
7
7
|
import { useInject } from "../../shared/hooks/useInject.js";
|
|
8
8
|
import { TYPES } from "../../shared/services/types.js";
|
|
9
|
+
import { validateContext } from "../../core/context/validate-context.js";
|
|
9
10
|
import crypto from "node:crypto";
|
|
10
11
|
import { t } from "../../shared/i18n/index.js";
|
|
11
12
|
import { joinCommandLines, parseCommandLine } from "../../loop-config.js";
|
|
@@ -36,6 +37,8 @@ export function TaskForm(props) {
|
|
|
36
37
|
const chainOptions = useMemo(() => [t("wizard.chainNone"), ...tasks.map((task) => task.name)], [tasks]);
|
|
37
38
|
const chainSelectOptions = useMemo(() => chainOptions.map((v) => ({ value: v, label: v })), [chainOptions]);
|
|
38
39
|
const [commandEditorOpen, setCommandEditorOpen] = useState(false);
|
|
40
|
+
const [contextEditorOpen, setContextEditorOpen] = useState(false);
|
|
41
|
+
const [contextValue, setContextValue] = useState(editTask?.context ? JSON.stringify(editTask.context) : "");
|
|
39
42
|
const [openChainField, setOpenChainField] = useState(null);
|
|
40
43
|
const chainFieldsRef = useRef({});
|
|
41
44
|
const resolveChainId = useCallback((val) => {
|
|
@@ -94,9 +97,32 @@ export function TaskForm(props) {
|
|
|
94
97
|
return (_jsx(SelectValueField, { label: value || null, placeholder: t("wizard.onFailurePrompt"), isActive: isActive }));
|
|
95
98
|
},
|
|
96
99
|
},
|
|
100
|
+
{
|
|
101
|
+
key: "context",
|
|
102
|
+
prompt: t("wizard.contextPrompt"),
|
|
103
|
+
hint: t("wizard.contextHint"),
|
|
104
|
+
required: false,
|
|
105
|
+
inputType: "text",
|
|
106
|
+
defaultValue: contextValue || undefined,
|
|
107
|
+
onActivate: () => setContextEditorOpen(true),
|
|
108
|
+
validate: (value) => {
|
|
109
|
+
if (!value?.trim())
|
|
110
|
+
return true;
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(value);
|
|
113
|
+
const result = validateContext(parsed);
|
|
114
|
+
return result.valid;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
validationError: t("wizard.contextInvalid"),
|
|
121
|
+
renderCustom: ({ isActive }) => (_jsx(CodeEditorPreview, { value: contextValue, hint: t("wizard.contextHint"), isActive: isActive, onActivate: () => setContextEditorOpen(true) })),
|
|
122
|
+
},
|
|
97
123
|
];
|
|
98
124
|
return list;
|
|
99
|
-
}, [commandValue, chainOptions, editTask, resolveChainName]);
|
|
125
|
+
}, [commandValue, chainOptions, editTask, resolveChainName, contextValue]);
|
|
100
126
|
const handleComplete = useCallback((values) => {
|
|
101
127
|
const name = values.name?.trim() ?? "";
|
|
102
128
|
const rawCommand = joinCommandLines(commandValue);
|
|
@@ -104,6 +130,20 @@ export function TaskForm(props) {
|
|
|
104
130
|
return;
|
|
105
131
|
const onSuccessTaskId = resolveChainId(values.onSuccess ?? "");
|
|
106
132
|
const onFailureTaskId = resolveChainId(values.onFailure ?? "");
|
|
133
|
+
let context;
|
|
134
|
+
const contextStr = contextValue?.trim();
|
|
135
|
+
if (contextStr) {
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(contextStr);
|
|
138
|
+
const result = validateContext(parsed);
|
|
139
|
+
if (result.valid) {
|
|
140
|
+
context = result.context;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// invalid context, skip
|
|
145
|
+
}
|
|
146
|
+
}
|
|
107
147
|
const commandSegments = commandValue
|
|
108
148
|
.split(/\n&&\n|\n&&|&&\n|&&/)
|
|
109
149
|
.map(s => s.trim())
|
|
@@ -136,6 +176,7 @@ export function TaskForm(props) {
|
|
|
136
176
|
steps,
|
|
137
177
|
onSuccessTaskId,
|
|
138
178
|
onFailureTaskId,
|
|
179
|
+
context,
|
|
139
180
|
};
|
|
140
181
|
}
|
|
141
182
|
else {
|
|
@@ -146,6 +187,7 @@ export function TaskForm(props) {
|
|
|
146
187
|
commandRaw: commandValue,
|
|
147
188
|
onSuccessTaskId,
|
|
148
189
|
onFailureTaskId,
|
|
190
|
+
context,
|
|
149
191
|
};
|
|
150
192
|
}
|
|
151
193
|
if (mode === "edit" && editTask) {
|
|
@@ -159,16 +201,19 @@ export function TaskForm(props) {
|
|
|
159
201
|
.then((result) => onDone(false, result.id))
|
|
160
202
|
.catch(() => { });
|
|
161
203
|
}
|
|
162
|
-
}, [commandValue, resolveChainId, mode, editTask, onDone]);
|
|
204
|
+
}, [commandValue, resolveChainId, mode, editTask, onDone, contextValue]);
|
|
163
205
|
const title = mode === "edit"
|
|
164
206
|
? t("board.taskEditTitle")
|
|
165
207
|
: t("board.taskCreateTitle");
|
|
166
|
-
return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: title, steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: openChainField !== null || commandEditorOpen }), openChainField ? (_jsx(SelectModal, { title: openChainField === "onSuccess" ? t("wizard.onSuccessPrompt") : t("wizard.onFailurePrompt"), options: chainSelectOptions, initialValue: chainFieldsRef.current[openChainField]?.value, onSelect: (option) => {
|
|
208
|
+
return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: title, steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: openChainField !== null || commandEditorOpen || contextEditorOpen }), openChainField ? (_jsx(SelectModal, { title: openChainField === "onSuccess" ? t("wizard.onSuccessPrompt") : t("wizard.onFailurePrompt"), options: chainSelectOptions, initialValue: chainFieldsRef.current[openChainField]?.value, onSelect: (option) => {
|
|
167
209
|
chainFieldsRef.current[openChainField]?.onChange(option.value);
|
|
168
210
|
chainFieldsRef.current[openChainField]?.onAdvance();
|
|
169
211
|
setOpenChainField(null);
|
|
170
212
|
}, onClose: () => setOpenChainField(null) })) : null, commandEditorOpen ? (_jsx(CodeEditorModal, { initialValue: commandValue, onSave: (v) => {
|
|
171
213
|
setCommandValue(v);
|
|
172
214
|
setCommandEditorOpen(false);
|
|
173
|
-
}, onCancel: () => setCommandEditorOpen(false) })) : null
|
|
215
|
+
}, onCancel: () => setCommandEditorOpen(false) })) : null, contextEditorOpen ? (_jsx(CodeEditorModal, { initialValue: contextValue, onSave: (v) => {
|
|
216
|
+
setContextValue(v);
|
|
217
|
+
setContextEditorOpen(false);
|
|
218
|
+
}, onCancel: () => setContextEditorOpen(false) })) : null] }));
|
|
174
219
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loop-task",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.12",
|
|
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": {
|