feat(KB-229): add multi-step scheduled tasks

- Add Step types to core data model (command, validation, approval steps)
- Update AutomationStore CRUD operations for step management
- Refactor CronRunner for sequential step execution with per-step config
- Add Dashboard API routes for step CRUD and step-aware schedule updates
- Create ScheduleStepsEditor component for visual step configuration
- Add StepTypeBadge component for step type visualization
- Update ScheduleCard and ScheduleForm to display and manage steps
- Add comprehensive test coverage for stores, runner, and components
- Add changeset for patch release
This commit is contained in:
gsxdsm
2026-03-31 05:14:30 -07:00
parent f1a849b6fa
commit 4be8306f7a
16 changed files with 2127 additions and 131 deletions

View File

@@ -2,7 +2,7 @@ import { Router, type Request, type Response, type NextFunction } from "express"
import multer from "multer";
import { createReadStream, existsSync } from "node:fs";
import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset } from "@kb/core";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep } from "@kb/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
@@ -3906,16 +3906,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return res.status(503).json({ error: "Automation store not available" });
}
try {
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = req.body;
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body;
// Validation
if (!name?.trim()) {
return res.status(400).json({ error: "Name is required" });
}
if (!command?.trim()) {
return res.status(400).json({ error: "Command is required" });
const hasSteps = Array.isArray(steps) && steps.length > 0;
if (!hasSteps && !command?.trim()) {
return res.status(400).json({ error: "Command is required when no steps are provided" });
}
const validTypes = ["hourly", "daily", "weekly", "monthly", "custom"];
const validTypes = ["hourly", "daily", "weekly", "monthly", "custom", "every15Minutes", "every30Minutes", "every2Hours", "every6Hours", "every12Hours", "weekdays"];
if (!scheduleType || !validTypes.includes(scheduleType)) {
return res.status(400).json({ error: `Invalid schedule type. Must be one of: ${validTypes.join(", ")}` });
}
@@ -3927,15 +3928,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return res.status(400).json({ error: `Invalid cron expression: "${cronExpression}"` });
}
}
// Validate steps if provided
if (hasSteps) {
const stepErr = validateAutomationSteps(steps);
if (stepErr) {
return res.status(400).json({ error: stepErr });
}
}
const schedule = await automationStore.createSchedule({
name,
description,
scheduleType: scheduleType as ScheduleType,
cronExpression,
command,
command: command ?? "",
enabled,
timeoutMs,
steps: hasSteps ? steps : undefined,
});
res.status(201).json(schedule);
} catch (err: any) {
@@ -3967,7 +3976,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = req.body;
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body;
// Validate cron if switching to custom
if (scheduleType === "custom" && cronExpression) {
@@ -3976,6 +3985,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
// Validate steps if provided
if (Array.isArray(steps) && steps.length > 0) {
const stepErr = validateAutomationSteps(steps);
if (stepErr) {
return res.status(400).json({ error: stepErr });
}
}
const schedule = await automationStore.updateSchedule(id, {
name,
description,
@@ -3984,6 +4001,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
command,
enabled,
timeoutMs,
steps: steps !== undefined ? steps : undefined,
});
res.json(schedule);
} catch (err: any) {
@@ -4023,59 +4041,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const schedule = await automationStore.getSchedule(id);
// Execute the command directly
const { exec } = await import("node:child_process");
const { promisify } = await import("node:util");
const execAsync = promisify(exec);
const startedAt = new Date().toISOString();
let result: import("@kb/core").AutomationRunResult;
try {
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const MAX_BUFFER = 1024 * 1024;
const { stdout, stderr } = await execAsync(schedule.command, {
timeout: schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
shell: "/bin/sh",
});
let output = stdout;
if (stderr) {
output += stdout ? "\n--- stderr ---\n" : "";
output += stderr;
}
if (output.length > 10240) {
output = output.slice(0, 10240) + "\n[output truncated]";
}
result = {
success: true,
output,
startedAt,
completedAt: new Date().toISOString(),
};
} catch (err: any) {
const stdout = err.stdout ?? "";
const stderr = err.stderr ?? "";
let output = stdout;
if (stderr) {
output += stdout ? "\n--- stderr ---\n" : "";
output += stderr;
}
if (output.length > 10240) {
output = output.slice(0, 10240) + "\n[output truncated]";
}
result = {
success: false,
output,
error: err.killed
? `Command timed out after ${(schedule.timeoutMs ?? 300000) / 1000}s`
: err.message ?? String(err),
startedAt,
completedAt: new Date().toISOString(),
};
if (schedule.steps && schedule.steps.length > 0) {
// Multi-step execution
result = await executeScheduleSteps(schedule, startedAt);
} else {
// Legacy single-command execution
result = await executeSingleCommand(schedule.command, schedule.timeoutMs, startedAt);
}
// Record the result
@@ -4109,6 +4083,30 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// POST /automations/:id/steps/reorder — reorder steps
router.post("/automations/:id/steps/reorder", async (req, res) => {
if (!automationStore) {
return res.status(503).json({ error: "Automation store not available" });
}
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const { stepIds } = req.body;
if (!Array.isArray(stepIds)) {
return res.status(400).json({ error: "stepIds must be an array" });
}
const schedule = await automationStore.reorderSteps(id, stepIds);
res.json(schedule);
} catch (err: any) {
if (err.code === "ENOENT") {
return res.status(404).json({ error: "Schedule not found" });
}
if (err.message?.includes("mismatch") || err.message?.includes("Unknown step") || err.message?.includes("no steps")) {
return res.status(400).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
// ── Activity Log Routes ─────────────────────────────────────────────
/**
@@ -4376,6 +4374,199 @@ Output ONLY the prompt text (no markdown, no explanations).`;
return router;
}
// ── Automation step helpers ─────────────────────────────────────────
/**
* Validate an array of automation steps.
* Returns an error string if invalid, or null if valid.
*/
function validateAutomationSteps(steps: unknown[]): string | null {
for (let i = 0; i < steps.length; i++) {
const step = steps[i] as Record<string, unknown>;
if (!step.id || typeof step.id !== "string") {
return `Step ${i + 1}: id is required`;
}
if (!step.type || (step.type !== "command" && step.type !== "ai-prompt")) {
return `Step ${i + 1}: type must be "command" or "ai-prompt"`;
}
if (!step.name || typeof step.name !== "string" || !step.name.trim()) {
return `Step ${i + 1}: name is required`;
}
if (step.type === "command") {
if (!step.command || typeof step.command !== "string" || !step.command.trim()) {
return `Step ${i + 1}: command is required for command steps`;
}
}
if (step.type === "ai-prompt") {
if (!step.prompt || typeof step.prompt !== "string" || !step.prompt.trim()) {
return `Step ${i + 1}: prompt is required for ai-prompt steps`;
}
}
// Validate model fields are both present or both absent
const hasProvider = step.modelProvider && typeof step.modelProvider === "string";
const hasModelId = step.modelId && typeof step.modelId === "string";
if ((hasProvider && !hasModelId) || (!hasProvider && hasModelId)) {
return `Step ${i + 1}: modelProvider and modelId must both be present or both absent`;
}
}
return null;
}
/**
* Execute a single shell command (used by manual run endpoint).
*/
async function executeSingleCommand(
command: string,
timeoutMs: number | undefined,
startedAt: string,
): Promise<import("@kb/core").AutomationRunResult> {
const { exec } = await import("node:child_process");
const { promisify } = await import("node:util");
const execAsyncFn = promisify(exec);
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const MAX_BUFFER = 1024 * 1024;
const MAX_OUTPUT = 10240;
try {
const { stdout, stderr } = await execAsyncFn(command, {
timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
shell: "/bin/sh",
});
let output = stdout;
if (stderr) {
output += stdout ? "\n--- stderr ---\n" : "";
output += stderr;
}
if (output.length > MAX_OUTPUT) {
output = output.slice(0, MAX_OUTPUT) + "\n[output truncated]";
}
return { success: true, output, startedAt, completedAt: new Date().toISOString() };
} catch (err: any) {
const stdout = err.stdout ?? "";
const stderr = err.stderr ?? "";
let output = stdout;
if (stderr) {
output += stdout ? "\n--- stderr ---\n" : "";
output += stderr;
}
if (output.length > MAX_OUTPUT) {
output = output.slice(0, MAX_OUTPUT) + "\n[output truncated]";
}
return {
success: false,
output,
error: err.killed
? `Command timed out after ${(timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s`
: err.message ?? String(err),
startedAt,
completedAt: new Date().toISOString(),
};
}
}
/**
* Execute all steps in a multi-step schedule (used by manual run endpoint).
*/
async function executeScheduleSteps(
schedule: import("@kb/core").ScheduledTask,
startedAt: string,
): Promise<import("@kb/core").AutomationRunResult> {
const steps = schedule.steps!;
const stepResults: import("@kb/core").AutomationStepResult[] = [];
let overallSuccess = true;
let stoppedEarly = false;
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const stepStartedAt = new Date().toISOString();
const timeoutMs = step.timeoutMs ?? schedule.timeoutMs ?? 300000;
let stepResult: import("@kb/core").AutomationStepResult;
if (step.type === "command") {
const cmdResult = await executeSingleCommand(step.command ?? "", timeoutMs, stepStartedAt);
stepResult = {
stepId: step.id,
stepName: step.name,
stepIndex: i,
success: cmdResult.success,
output: cmdResult.output,
error: cmdResult.error,
startedAt: stepStartedAt,
completedAt: cmdResult.completedAt,
};
} else if (step.type === "ai-prompt") {
// AI prompt steps return a placeholder in manual run mode
const model = step.modelProvider && step.modelId
? `${step.modelProvider}/${step.modelId}`
: "default";
stepResult = {
stepId: step.id,
stepName: step.name,
stepIndex: i,
success: !!step.prompt?.trim(),
output: step.prompt?.trim()
? `[AI prompt step — model: ${model}]\nPrompt: ${step.prompt}`
: "",
error: step.prompt?.trim() ? undefined : "AI prompt step has no prompt specified",
startedAt: stepStartedAt,
completedAt: new Date().toISOString(),
};
} else {
stepResult = {
stepId: step.id,
stepName: step.name,
stepIndex: i,
success: false,
output: "",
error: `Unknown step type: "${step.type}"`,
startedAt: stepStartedAt,
completedAt: new Date().toISOString(),
};
}
stepResults.push(stepResult);
if (!stepResult.success) {
overallSuccess = false;
if (!step.continueOnFailure) {
stoppedEarly = true;
break;
}
}
}
// Aggregate output
const outputParts: string[] = [];
for (const sr of stepResults) {
outputParts.push(`=== Step ${sr.stepIndex + 1}: ${sr.stepName} (${sr.success ? "success" : "FAILED"}) ===`);
if (sr.output) outputParts.push(sr.output);
if (sr.error) outputParts.push(`Error: ${sr.error}`);
}
let output = outputParts.join("\n");
if (output.length > 10240) {
output = output.slice(0, 10240) + "\n[output truncated]";
}
const failedSteps = stepResults.filter((sr) => !sr.success);
const error = failedSteps.length > 0
? `${failedSteps.length} step(s) failed: ${failedSteps.map((s) => s.stepName).join(", ")}${stoppedEarly ? " (execution stopped)" : ""}`
: undefined;
return {
success: overallSuccess,
output,
error,
startedAt,
completedAt: new Date().toISOString(),
stepResults,
};
}
function getDefaultGitHubRepo(store: TaskStore): { owner: string; repo: string } | null {
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {