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

@@ -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", () => {

View File

@@ -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);

View File

@@ -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. */

View File

@@ -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";