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:
@@ -4,7 +4,19 @@ import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { ScheduledTask, AutomationRunResult } from "./automation.js";
|
||||
import type { ScheduledTask, AutomationRunResult, AutomationStep } from "./automation.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/** Create a test automation step. */
|
||||
function makeStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: "command",
|
||||
name: "Test step",
|
||||
command: "echo hello",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-automation-test-"));
|
||||
@@ -127,12 +139,25 @@ describe("AutomationStore", () => {
|
||||
).rejects.toThrow("Name is required");
|
||||
});
|
||||
|
||||
it("rejects empty command", async () => {
|
||||
it("rejects empty command when no steps are provided", async () => {
|
||||
await expect(
|
||||
store.createSchedule({ name: "Test", command: "", scheduleType: "hourly" }),
|
||||
).rejects.toThrow("Command is required");
|
||||
});
|
||||
|
||||
it("allows empty command when steps are provided", async () => {
|
||||
const step = makeStep();
|
||||
const schedule = await store.createSchedule({
|
||||
name: "Steps only",
|
||||
command: "",
|
||||
scheduleType: "hourly",
|
||||
steps: [step],
|
||||
});
|
||||
expect(schedule.steps).toHaveLength(1);
|
||||
expect(schedule.steps![0].id).toBe(step.id);
|
||||
expect(schedule.command).toBe("");
|
||||
});
|
||||
|
||||
it("rejects custom type without cron expression", async () => {
|
||||
await expect(
|
||||
store.createSchedule({ name: "Test", command: "echo", scheduleType: "custom" }),
|
||||
@@ -501,6 +526,186 @@ describe("AutomationStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Steps persistence ─────────────────────────────────────────────
|
||||
|
||||
describe("steps", () => {
|
||||
it("creates schedule with steps and persists them", async () => {
|
||||
const steps: AutomationStep[] = [
|
||||
makeStep({ name: "Step A", command: "echo a" }),
|
||||
makeStep({ name: "Step B", type: "ai-prompt", prompt: "Summarize", command: undefined }),
|
||||
];
|
||||
const schedule = await store.createSchedule({
|
||||
name: "Multi-step",
|
||||
command: "",
|
||||
scheduleType: "daily",
|
||||
steps,
|
||||
});
|
||||
|
||||
expect(schedule.steps).toHaveLength(2);
|
||||
expect(schedule.steps![0].name).toBe("Step A");
|
||||
expect(schedule.steps![1].type).toBe("ai-prompt");
|
||||
|
||||
// Verify round-trip persistence
|
||||
const fetched = await store.getSchedule(schedule.id);
|
||||
expect(fetched.steps).toHaveLength(2);
|
||||
expect(fetched.steps![0].id).toBe(steps[0].id);
|
||||
expect(fetched.steps![1].prompt).toBe("Summarize");
|
||||
});
|
||||
|
||||
it("creates schedule without steps (legacy mode)", async () => {
|
||||
const schedule = await store.createSchedule({
|
||||
name: "Legacy",
|
||||
command: "echo hello",
|
||||
scheduleType: "hourly",
|
||||
});
|
||||
|
||||
expect(schedule.steps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates steps on existing schedule", async () => {
|
||||
const schedule = await store.createSchedule({
|
||||
name: "Updateable",
|
||||
command: "echo old",
|
||||
scheduleType: "hourly",
|
||||
});
|
||||
expect(schedule.steps).toBeUndefined();
|
||||
|
||||
const steps = [makeStep({ name: "New step" })];
|
||||
const updated = await store.updateSchedule(schedule.id, { steps });
|
||||
expect(updated.steps).toHaveLength(1);
|
||||
expect(updated.steps![0].name).toBe("New step");
|
||||
});
|
||||
|
||||
it("clears steps when updating with empty array", async () => {
|
||||
const schedule = await store.createSchedule({
|
||||
name: "Clear steps",
|
||||
command: "echo hello",
|
||||
scheduleType: "hourly",
|
||||
steps: [makeStep()],
|
||||
});
|
||||
expect(schedule.steps).toHaveLength(1);
|
||||
|
||||
const updated = await store.updateSchedule(schedule.id, { steps: [] });
|
||||
expect(updated.steps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves step model fields through round-trip", async () => {
|
||||
const step = makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "AI Step",
|
||||
prompt: "Analyze this",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
timeoutMs: 60000,
|
||||
continueOnFailure: true,
|
||||
command: undefined,
|
||||
});
|
||||
const schedule = await store.createSchedule({
|
||||
name: "AI schedule",
|
||||
command: "",
|
||||
scheduleType: "daily",
|
||||
steps: [step],
|
||||
});
|
||||
|
||||
const fetched = await store.getSchedule(schedule.id);
|
||||
const fetchedStep = fetched.steps![0];
|
||||
expect(fetchedStep.type).toBe("ai-prompt");
|
||||
expect(fetchedStep.prompt).toBe("Analyze this");
|
||||
expect(fetchedStep.modelProvider).toBe("anthropic");
|
||||
expect(fetchedStep.modelId).toBe("claude-sonnet-4-5");
|
||||
expect(fetchedStep.timeoutMs).toBe(60000);
|
||||
expect(fetchedStep.continueOnFailure).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── reorderSteps ──────────────────────────────────────────────────
|
||||
|
||||
describe("reorderSteps", () => {
|
||||
it("reorders steps by ID array", async () => {
|
||||
const stepA = makeStep({ name: "A" });
|
||||
const stepB = makeStep({ name: "B" });
|
||||
const stepC = makeStep({ name: "C" });
|
||||
const schedule = await store.createSchedule({
|
||||
name: "Reorder test",
|
||||
command: "",
|
||||
scheduleType: "daily",
|
||||
steps: [stepA, stepB, stepC],
|
||||
});
|
||||
|
||||
const reordered = await store.reorderSteps(
|
||||
schedule.id,
|
||||
[stepC.id, stepA.id, stepB.id],
|
||||
);
|
||||
|
||||
expect(reordered.steps![0].name).toBe("C");
|
||||
expect(reordered.steps![1].name).toBe("A");
|
||||
expect(reordered.steps![2].name).toBe("B");
|
||||
|
||||
// Verify persisted
|
||||
const fetched = await store.getSchedule(schedule.id);
|
||||
expect(fetched.steps![0].name).toBe("C");
|
||||
});
|
||||
|
||||
it("throws when schedule has no steps", async () => {
|
||||
const schedule = await store.createSchedule({
|
||||
name: "No steps",
|
||||
command: "echo",
|
||||
scheduleType: "hourly",
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.reorderSteps(schedule.id, []),
|
||||
).rejects.toThrow("no steps to reorder");
|
||||
});
|
||||
|
||||
it("throws on step ID count mismatch", async () => {
|
||||
const stepA = makeStep({ name: "A" });
|
||||
const stepB = makeStep({ name: "B" });
|
||||
const schedule = await store.createSchedule({
|
||||
name: "Mismatch test",
|
||||
command: "",
|
||||
scheduleType: "daily",
|
||||
steps: [stepA, stepB],
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.reorderSteps(schedule.id, [stepA.id]),
|
||||
).rejects.toThrow("count mismatch");
|
||||
});
|
||||
|
||||
it("throws on unknown step ID", async () => {
|
||||
const stepA = makeStep({ name: "A" });
|
||||
const stepB = makeStep({ name: "B" });
|
||||
const schedule = await store.createSchedule({
|
||||
name: "Unknown ID test",
|
||||
command: "",
|
||||
scheduleType: "daily",
|
||||
steps: [stepA, stepB],
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.reorderSteps(schedule.id, [stepA.id, "nonexistent"]),
|
||||
).rejects.toThrow('Unknown step ID: "nonexistent"');
|
||||
});
|
||||
|
||||
it("emits schedule:updated event", async () => {
|
||||
const stepA = makeStep({ name: "A" });
|
||||
const stepB = makeStep({ name: "B" });
|
||||
const schedule = await store.createSchedule({
|
||||
name: "Event test",
|
||||
command: "",
|
||||
scheduleType: "daily",
|
||||
steps: [stepA, stepB],
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
store.on("schedule:updated", listener);
|
||||
|
||||
await store.reorderSteps(schedule.id, [stepB.id, stepA.id]);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Concurrent write safety ───────────────────────────────────────
|
||||
|
||||
describe("concurrency", () => {
|
||||
|
||||
@@ -122,7 +122,8 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
||||
if (!input.name?.trim()) {
|
||||
throw new Error("Name is required and cannot be empty");
|
||||
}
|
||||
if (!input.command?.trim()) {
|
||||
const hasSteps = input.steps && input.steps.length > 0;
|
||||
if (!hasSteps && !input.command?.trim()) {
|
||||
throw new Error("Command is required and cannot be empty");
|
||||
}
|
||||
|
||||
@@ -150,11 +151,12 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
||||
description: input.description?.trim() || undefined,
|
||||
scheduleType: input.scheduleType,
|
||||
cronExpression,
|
||||
command: input.command.trim(),
|
||||
command: (input.command ?? "").trim(),
|
||||
enabled,
|
||||
runCount: 0,
|
||||
runHistory: [],
|
||||
timeoutMs: input.timeoutMs,
|
||||
steps: hasSteps ? input.steps : undefined,
|
||||
nextRunAt: enabled ? this.computeNextRun(cronExpression) : undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -207,6 +209,9 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
||||
if (!updates.command.trim()) throw new Error("Command cannot be empty");
|
||||
schedule.command = updates.command.trim();
|
||||
}
|
||||
if (updates.steps !== undefined) {
|
||||
schedule.steps = updates.steps.length > 0 ? updates.steps : undefined;
|
||||
}
|
||||
if (updates.timeoutMs !== undefined) {
|
||||
schedule.timeoutMs = updates.timeoutMs;
|
||||
}
|
||||
@@ -251,6 +256,40 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder the steps of a schedule by providing the step IDs in the desired order.
|
||||
* The `stepIds` array must contain exactly the same IDs as the current steps.
|
||||
*/
|
||||
async reorderSteps(scheduleId: string, stepIds: string[]): Promise<ScheduledTask> {
|
||||
return this.withScheduleLock(scheduleId, async () => {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
if (!schedule.steps || schedule.steps.length === 0) {
|
||||
throw new Error("Schedule has no steps to reorder");
|
||||
}
|
||||
if (stepIds.length !== schedule.steps.length) {
|
||||
throw new Error(
|
||||
`Step ID count mismatch: expected ${schedule.steps.length}, got ${stepIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stepMap = new Map(schedule.steps.map((s) => [s.id, s]));
|
||||
const reordered = [];
|
||||
for (const id of stepIds) {
|
||||
const step = stepMap.get(id);
|
||||
if (!step) {
|
||||
throw new Error(`Unknown step ID: "${id}"`);
|
||||
}
|
||||
reordered.push(step);
|
||||
}
|
||||
|
||||
schedule.steps = reordered;
|
||||
schedule.updatedAt = new Date().toISOString();
|
||||
await this.atomicWriteScheduleJson(scheduleId, schedule);
|
||||
this.emit("schedule:updated", schedule);
|
||||
return schedule;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string): Promise<ScheduledTask> {
|
||||
return this.withScheduleLock(id, async () => {
|
||||
const schedule = await this.getSchedule(id);
|
||||
|
||||
@@ -15,6 +15,53 @@ export const AUTOMATION_PRESETS: Record<Exclude<ScheduleType, "custom">, string>
|
||||
weekdays: "0 9 * * 1-5",
|
||||
};
|
||||
|
||||
// ── Automation Step Types ────────────────────────────────────────────
|
||||
|
||||
/** The type of an automation step. */
|
||||
export type AutomationStepType = "command" | "ai-prompt";
|
||||
|
||||
/** A single step within a multi-step scheduled task. */
|
||||
export interface AutomationStep {
|
||||
/** Unique step identifier (UUID). */
|
||||
id: string;
|
||||
/** The type of this step. */
|
||||
type: AutomationStepType;
|
||||
/** Human-readable step name. */
|
||||
name: string;
|
||||
/** Shell command to execute (for command steps). */
|
||||
command?: string;
|
||||
/** AI prompt to run (for ai-prompt steps). */
|
||||
prompt?: string;
|
||||
/** AI model provider (for ai-prompt steps). */
|
||||
modelProvider?: string;
|
||||
/** AI model ID (for ai-prompt steps). */
|
||||
modelId?: string;
|
||||
/** Per-step timeout override in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Whether to continue to the next step if this one fails. Default: false. */
|
||||
continueOnFailure?: boolean;
|
||||
}
|
||||
|
||||
/** Result of executing a single automation step. */
|
||||
export interface AutomationStepResult {
|
||||
/** Step ID that produced this result. */
|
||||
stepId: string;
|
||||
/** Step name (for display). */
|
||||
stepName: string;
|
||||
/** Zero-based index of the step. */
|
||||
stepIndex: number;
|
||||
/** Whether the step completed successfully. */
|
||||
success: boolean;
|
||||
/** Output from the step. */
|
||||
output: string;
|
||||
/** Error message if the step failed. */
|
||||
error?: string;
|
||||
/** ISO-8601 timestamp of when this step started. */
|
||||
startedAt: string;
|
||||
/** ISO-8601 timestamp of when this step completed. */
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
/** Result of a single automation run. */
|
||||
export interface AutomationRunResult {
|
||||
success: boolean;
|
||||
@@ -22,6 +69,8 @@ export interface AutomationRunResult {
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
/** Per-step results (present only for multi-step schedules). */
|
||||
stepResults?: AutomationStepResult[];
|
||||
}
|
||||
|
||||
/** A scheduled automation task. */
|
||||
@@ -36,8 +85,12 @@ export interface ScheduledTask {
|
||||
scheduleType: ScheduleType;
|
||||
/** The cron expression (auto-derived from preset or user-supplied for custom). */
|
||||
cronExpression: string;
|
||||
/** The shell command to execute. */
|
||||
/** The shell command to execute (legacy single-command mode). */
|
||||
command: string;
|
||||
/** Multi-step workflow. When present, steps execute sequentially instead of `command`. */
|
||||
steps?: AutomationStep[];
|
||||
/** Index of the step currently being executed (runtime only, not persisted as running state). */
|
||||
currentStepIndex?: number;
|
||||
/** Whether this schedule is currently active. */
|
||||
enabled: boolean;
|
||||
/** ISO-8601 timestamp of the last run start, if any. */
|
||||
@@ -65,9 +118,12 @@ export interface ScheduledTaskCreateInput {
|
||||
scheduleType: ScheduleType;
|
||||
/** Required for 'custom' type; ignored for presets (auto-derived). */
|
||||
cronExpression?: string;
|
||||
/** Shell command (legacy single-command mode). Required if `steps` is not provided. */
|
||||
command: string;
|
||||
enabled?: boolean;
|
||||
timeoutMs?: number;
|
||||
/** Multi-step workflow. When provided, `command` is ignored in favor of sequential step execution. */
|
||||
steps?: AutomationStep[];
|
||||
}
|
||||
|
||||
/** Input for updating an existing scheduled task. */
|
||||
@@ -79,6 +135,8 @@ export interface ScheduledTaskUpdateInput {
|
||||
command?: string;
|
||||
enabled?: boolean;
|
||||
timeoutMs?: number;
|
||||
/** Multi-step workflow. When provided, `command` is ignored in favor of sequential step execution. */
|
||||
steps?: AutomationStep[];
|
||||
}
|
||||
|
||||
/** Maximum number of run history entries to retain per schedule. */
|
||||
|
||||
@@ -17,6 +17,6 @@ export {
|
||||
type GhError,
|
||||
} from "./gh-cli.js";
|
||||
export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
|
||||
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult } from "./automation.js";
|
||||
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js";
|
||||
export { AutomationStore } from "./automation-store.js";
|
||||
export type { AutomationStoreEvents } from "./automation-store.js";
|
||||
|
||||
Reference in New Issue
Block a user