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 62f87947f6
commit 7569af8a93
16 changed files with 2127 additions and 131 deletions

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": minor
---
Add multi-step scheduled tasks with command and AI prompt steps. Scheduled tasks can now contain multiple steps that execute sequentially, with per-step timeout overrides and continue-on-failure options. The dashboard UI includes a step editor with add/edit/delete/reorder functionality and a Simple/Multi-Step mode toggle.

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

View File

@@ -18,7 +18,7 @@ import type {
WorkflowStepInput,
} from "@kb/core";
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@kb/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult } from "@kb/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep } from "@kb/core";
function looksLikeHtml(body: string): boolean {
const trimmed = body.trim();
@@ -1093,18 +1093,18 @@ export function fetchAutomation(id: string): Promise<ScheduledTask> {
}
export function createAutomation(input: ScheduledTaskCreateInput): Promise<ScheduledTask> {
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = input;
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = input;
return api<ScheduledTask>("/automations", {
method: "POST",
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs }),
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps }),
});
}
export function updateAutomation(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> {
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = updates;
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = updates;
return api<ScheduledTask>(`/automations/${id}`, {
method: "PATCH",
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs }),
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps }),
});
}
@@ -1126,6 +1126,13 @@ export function toggleAutomation(id: string): Promise<ScheduledTask> {
});
}
export function reorderAutomationSteps(id: string, stepIds: string[]): Promise<ScheduledTask> {
return api<ScheduledTask>(`/automations/${id}/steps/reorder`, {
method: "POST",
body: JSON.stringify({ stepIds }),
});
}
// ── Activity Log API ────────────────────────────────────────────
/** Re-export ActivityLogEntry type from core for convenience */

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from "react";
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp } from "lucide-react";
import type { ScheduledTask, AutomationRunResult } from "@kb/core";
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Layers } from "lucide-react";
import type { ScheduledTask, AutomationRunResult, AutomationStepResult } from "@kb/core";
/**
* Format a duration in milliseconds to a human-readable string.
@@ -80,9 +80,24 @@ function RunResultBadge({ result }: { result: AutomationRunResult }) {
);
}
function StepResultIndicator({ stepResults }: { stepResults: AutomationStepResult[] }) {
return (
<span className="step-results-indicator">
{stepResults.map((sr) => (
<span
key={sr.stepId}
className={`step-result-dot ${sr.success ? "success" : "failure"}`}
title={`${sr.stepName}: ${sr.success ? "success" : "failed"}`}
/>
))}
</span>
);
}
function RunHistoryItem({ result, index }: { result: AutomationRunResult; index: number }) {
const [expanded, setExpanded] = useState(false);
const duration = new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime();
const hasStepResults = result.stepResults && result.stepResults.length > 0;
return (
<div className="schedule-history-item">
@@ -96,11 +111,25 @@ function RunHistoryItem({ result, index }: { result: AutomationRunResult; index:
{result.success ? <CheckCircle size={12} /> : <XCircle size={12} />}
</span>
<span className="schedule-history-time">{relativeTime(result.startedAt)}</span>
{hasStepResults && <StepResultIndicator stepResults={result.stepResults!} />}
<span className="schedule-history-duration">{formatDurationMs(duration)}</span>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{expanded && (
<div className="schedule-history-detail">
{hasStepResults && (
<div className="schedule-step-results">
{result.stepResults!.map((sr) => (
<div key={sr.stepId} className={`schedule-step-result ${sr.success ? "success" : "failure"}`}>
<span className="schedule-step-result-status">
{sr.success ? <CheckCircle size={10} /> : <XCircle size={10} />}
</span>
<span className="schedule-step-result-name">{sr.stepName}</span>
{sr.error && <span className="schedule-step-result-error">{sr.error}</span>}
</div>
))}
</div>
)}
{result.output && (
<pre className="schedule-history-output">{result.output}</pre>
)}
@@ -180,6 +209,16 @@ export function ScheduleCard({ schedule, onEdit, onDelete, onRun, onToggle, runn
</div>
<div className="schedule-card-meta">
{schedule.steps && schedule.steps.length > 0 ? (
<div className="schedule-meta-item">
<Layers size={12} />
<span className="schedule-steps-badge">{schedule.steps.length} step{schedule.steps.length !== 1 ? "s" : ""}</span>
</div>
) : (
<div className="schedule-meta-item schedule-meta-command-preview" title={schedule.command}>
<code className="schedule-command-preview">{schedule.command}</code>
</div>
)}
<div className="schedule-meta-item">
<Clock size={12} />
<code className="schedule-cron">{schedule.cronExpression}</code>

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect } from "react";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduleType } from "@kb/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduleType, AutomationStep } from "@kb/core";
import { ScheduleStepsEditor } from "./ScheduleStepsEditor";
/** Mapping from preset schedule types to their cron expressions. Mirrored from @kb/core. */
const PRESET_CRON: Record<Exclude<ScheduleType, "custom">, string> = {
@@ -40,6 +41,8 @@ function isLikelyCron(expr: string): boolean {
return parts.every((p) => /^[\d*,/\-]+$/.test(p));
}
type ScheduleMode = "simple" | "advanced";
interface ScheduleFormProps {
/** Existing schedule for editing. Omit for create mode. */
schedule?: ScheduledTask;
@@ -52,6 +55,10 @@ interface ScheduleFormProps {
export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps) {
const isEditing = !!schedule;
// Determine initial mode based on whether the schedule has steps
const initialMode: ScheduleMode = schedule?.steps && schedule.steps.length > 0 ? "advanced" : "simple";
const [mode, setMode] = useState<ScheduleMode>(initialMode);
const [name, setName] = useState(schedule?.name ?? "");
const [description, setDescription] = useState(schedule?.description ?? "");
const [scheduleType, setScheduleType] = useState<ScheduleType>(schedule?.scheduleType ?? "daily");
@@ -59,6 +66,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
const [command, setCommand] = useState(schedule?.command ?? "");
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
const [timeoutMs, setTimeoutMs] = useState<number>(schedule?.timeoutMs ?? 300000);
const [steps, setSteps] = useState<AutomationStep[]>(schedule?.steps ?? []);
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
@@ -73,7 +81,8 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
const validate = useCallback((): boolean => {
const e: Record<string, string> = {};
if (!name.trim()) e.name = "Name is required";
if (!command.trim()) e.command = "Command is required";
if (mode === "simple" && !command.trim()) e.command = "Command is required";
if (mode === "advanced" && steps.length === 0) e.steps = "At least one step is required";
if (scheduleType === "custom") {
if (!cronExpression.trim()) {
e.cronExpression = "Cron expression is required for custom schedules";
@@ -86,7 +95,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
}
setErrors(e);
return Object.keys(e).length === 0;
}, [name, command, scheduleType, cronExpression, timeoutMs]);
}, [name, command, mode, steps, scheduleType, cronExpression, timeoutMs]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -99,15 +108,16 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
description: description.trim() || undefined,
scheduleType,
cronExpression: scheduleType === "custom" ? cronExpression.trim() : undefined,
command: command.trim(),
command: mode === "simple" ? command.trim() : "",
enabled,
timeoutMs,
steps: mode === "advanced" ? steps : undefined,
});
} finally {
setSubmitting(false);
}
},
[validate, onSubmit, name, description, scheduleType, cronExpression, command, enabled, timeoutMs],
[validate, onSubmit, name, description, scheduleType, cronExpression, command, enabled, timeoutMs, mode, steps],
);
const cronFieldId = "schedule-cron";
@@ -189,24 +199,63 @@ export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps
)}
</div>
{/* Mode switcher */}
<div className="form-group">
<label htmlFor="schedule-command">Command</label>
<input
id="schedule-command"
type="text"
placeholder="e.g. npm run update-deps"
value={command}
onChange={(e) => setCommand(e.target.value)}
aria-invalid={!!errors.command}
aria-describedby={errors.command ? commandErrorId : undefined}
/>
{errors.command ? (
<small id={commandErrorId} className="field-error">{errors.command}</small>
) : (
<small>Shell command to execute. Runs with your user permissions.</small>
)}
<label>Execution Mode</label>
<div className="schedule-mode-toggle" role="radiogroup" aria-label="Execution mode">
<button
type="button"
className={`schedule-mode-btn${mode === "simple" ? " active" : ""}`}
onClick={() => setMode("simple")}
role="radio"
aria-checked={mode === "simple"}
>
Simple
</button>
<button
type="button"
className={`schedule-mode-btn${mode === "advanced" ? " active" : ""}`}
onClick={() => setMode("advanced")}
role="radio"
aria-checked={mode === "advanced"}
>
Multi-Step
</button>
</div>
<small>
{mode === "simple"
? "Run a single shell command"
: "Run multiple steps sequentially (commands and AI prompts)"}
</small>
</div>
{mode === "simple" ? (
<div className="form-group">
<label htmlFor="schedule-command">Command</label>
<input
id="schedule-command"
type="text"
placeholder="e.g. npm run update-deps"
value={command}
onChange={(e) => setCommand(e.target.value)}
aria-invalid={!!errors.command}
aria-describedby={errors.command ? commandErrorId : undefined}
/>
{errors.command ? (
<small id={commandErrorId} className="field-error">{errors.command}</small>
) : (
<small>Shell command to execute. Runs with your user permissions.</small>
)}
</div>
) : (
<>
<ScheduleStepsEditor steps={steps} onChange={setSteps} />
{errors.steps && (
<small className="field-error">{errors.steps}</small>
)}
</>
)}
<div className="form-group">
<label htmlFor="schedule-timeout">Timeout (ms)</label>
<input

View File

@@ -0,0 +1,323 @@
import { useState, useCallback } from "react";
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, GripVertical } from "lucide-react";
import type { AutomationStep, AutomationStepType } from "@kb/core";
import { StepTypeBadge } from "./StepTypeBadge";
interface ScheduleStepsEditorProps {
steps: AutomationStep[];
onChange: (steps: AutomationStep[]) => void;
}
function generateStepId(): string {
return crypto.randomUUID();
}
function createEmptyStep(type: AutomationStepType): AutomationStep {
return {
id: generateStepId(),
type,
name: type === "command" ? "New Command Step" : "New AI Prompt Step",
command: type === "command" ? "" : undefined,
prompt: type === "ai-prompt" ? "" : undefined,
continueOnFailure: false,
};
}
interface StepEditorProps {
step: AutomationStep;
onSave: (step: AutomationStep) => void;
onCancel: () => void;
}
function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
const [name, setName] = useState(step.name);
const [type, setType] = useState<AutomationStepType>(step.type);
const [command, setCommand] = useState(step.command ?? "");
const [prompt, setPrompt] = useState(step.prompt ?? "");
const [modelProvider, setModelProvider] = useState(step.modelProvider ?? "");
const [modelId, setModelId] = useState(step.modelId ?? "");
const [timeoutMs, setTimeoutMs] = useState<number | undefined>(step.timeoutMs);
const [continueOnFailure, setContinueOnFailure] = useState(step.continueOnFailure ?? false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = useCallback((): boolean => {
const e: Record<string, string> = {};
if (!name.trim()) e.name = "Step name is required";
if (type === "command" && !command.trim()) e.command = "Command is required";
if (type === "ai-prompt" && !prompt.trim()) e.prompt = "Prompt is required";
if (timeoutMs !== undefined && timeoutMs < 1000) {
e.timeoutMs = "Timeout must be at least 1 second (1000ms)";
}
if ((modelProvider && !modelId) || (!modelProvider && modelId)) {
e.model = "Both model provider and model ID must be set, or both empty";
}
setErrors(e);
return Object.keys(e).length === 0;
}, [name, type, command, prompt, timeoutMs, modelProvider, modelId]);
const handleSave = useCallback(() => {
if (!validate()) return;
onSave({
...step,
name: name.trim(),
type,
command: type === "command" ? command.trim() : undefined,
prompt: type === "ai-prompt" ? prompt.trim() : undefined,
modelProvider: type === "ai-prompt" && modelProvider ? modelProvider.trim() : undefined,
modelId: type === "ai-prompt" && modelId ? modelId.trim() : undefined,
timeoutMs: timeoutMs || undefined,
continueOnFailure,
});
}, [validate, onSave, step, name, type, command, prompt, modelProvider, modelId, timeoutMs, continueOnFailure]);
return (
<div className="step-editor">
<div className="form-group">
<label htmlFor={`step-name-${step.id}`}>Step Name</label>
<input
id={`step-name-${step.id}`}
type="text"
placeholder="e.g. Run tests"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
/>
{errors.name && <small className="field-error">{errors.name}</small>}
</div>
<div className="form-group">
<label htmlFor={`step-type-${step.id}`}>Step Type</label>
<select
id={`step-type-${step.id}`}
value={type}
onChange={(e) => setType(e.target.value as AutomationStepType)}
>
<option value="command">Command</option>
<option value="ai-prompt">AI Prompt</option>
</select>
</div>
{type === "command" && (
<div className="form-group">
<label htmlFor={`step-command-${step.id}`}>Command</label>
<textarea
id={`step-command-${step.id}`}
placeholder="e.g. npm test"
value={command}
onChange={(e) => setCommand(e.target.value)}
rows={2}
aria-invalid={!!errors.command}
/>
{errors.command && <small className="field-error">{errors.command}</small>}
</div>
)}
{type === "ai-prompt" && (
<>
<div className="form-group">
<label htmlFor={`step-prompt-${step.id}`}>Prompt</label>
<textarea
id={`step-prompt-${step.id}`}
placeholder="e.g. Summarize the test results and highlight any failures"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={3}
aria-invalid={!!errors.prompt}
/>
{errors.prompt && <small className="field-error">{errors.prompt}</small>}
</div>
<div className="form-group form-group-row">
<div className="form-group">
<label htmlFor={`step-provider-${step.id}`}>Model Provider (optional)</label>
<input
id={`step-provider-${step.id}`}
type="text"
placeholder="e.g. anthropic"
value={modelProvider}
onChange={(e) => setModelProvider(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor={`step-model-${step.id}`}>Model ID (optional)</label>
<input
id={`step-model-${step.id}`}
type="text"
placeholder="e.g. claude-sonnet-4-5"
value={modelId}
onChange={(e) => setModelId(e.target.value)}
/>
</div>
</div>
{errors.model && <small className="field-error">{errors.model}</small>}
</>
)}
<div className="form-group">
<label htmlFor={`step-timeout-${step.id}`}>Timeout (ms, optional)</label>
<input
id={`step-timeout-${step.id}`}
type="number"
min={1000}
step={1000}
placeholder="Override schedule timeout"
value={timeoutMs ?? ""}
onChange={(e) => setTimeoutMs(e.target.value ? Number(e.target.value) : undefined)}
aria-invalid={!!errors.timeoutMs}
/>
{errors.timeoutMs && <small className="field-error">{errors.timeoutMs}</small>}
</div>
<div className="form-group">
<label htmlFor={`step-continue-${step.id}`} className="checkbox-label">
<input
id={`step-continue-${step.id}`}
type="checkbox"
checked={continueOnFailure}
onChange={(e) => setContinueOnFailure(e.target.checked)}
/>
Continue on failure
</label>
<small>If checked, the next step will run even if this one fails</small>
</div>
<div className="step-editor-actions">
<button type="button" className="btn btn-sm" onClick={onCancel}>
Cancel
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={handleSave}>
Save Step
</button>
</div>
</div>
);
}
export function ScheduleStepsEditor({ steps, onChange }: ScheduleStepsEditorProps) {
const [editingStepId, setEditingStepId] = useState<string | null>(null);
const handleAddStep = useCallback((type: AutomationStepType) => {
const newStep = createEmptyStep(type);
onChange([...steps, newStep]);
setEditingStepId(newStep.id);
}, [steps, onChange]);
const handleDeleteStep = useCallback((stepId: string) => {
onChange(steps.filter((s) => s.id !== stepId));
if (editingStepId === stepId) setEditingStepId(null);
}, [steps, onChange, editingStepId]);
const handleMoveStep = useCallback((stepId: string, direction: "up" | "down") => {
const index = steps.findIndex((s) => s.id === stepId);
if (index < 0) return;
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= steps.length) return;
const newSteps = [...steps];
[newSteps[index], newSteps[newIndex]] = [newSteps[newIndex], newSteps[index]];
onChange(newSteps);
}, [steps, onChange]);
const handleSaveStep = useCallback((updatedStep: AutomationStep) => {
onChange(steps.map((s) => (s.id === updatedStep.id ? updatedStep : s)));
setEditingStepId(null);
}, [steps, onChange]);
return (
<div className="steps-editor">
<div className="steps-editor-header">
<span className="steps-editor-title">Steps ({steps.length})</span>
</div>
{steps.length === 0 && (
<div className="steps-empty-state">
<p>No steps added yet. Add a command or AI prompt step to get started.</p>
</div>
)}
<div className="steps-list">
{steps.map((step, index) => (
<div key={step.id} className="step-card">
{editingStepId === step.id ? (
<StepEditor
step={step}
onSave={handleSaveStep}
onCancel={() => setEditingStepId(null)}
/>
) : (
<div className="step-card-row">
<div className="step-card-drag">
<GripVertical size={14} />
</div>
<span className="step-card-index">{index + 1}</span>
<StepTypeBadge type={step.type} />
<span className="step-card-name">{step.name}</span>
{step.continueOnFailure && (
<span className="step-card-flag" title="Continues on failure">⚡</span>
)}
<div className="step-card-actions">
<button
type="button"
className="btn-icon"
onClick={() => handleMoveStep(step.id, "up")}
disabled={index === 0}
title="Move up"
aria-label={`Move ${step.name} up`}
>
<ChevronUp size={14} />
</button>
<button
type="button"
className="btn-icon"
onClick={() => handleMoveStep(step.id, "down")}
disabled={index === steps.length - 1}
title="Move down"
aria-label={`Move ${step.name} down`}
>
<ChevronDown size={14} />
</button>
<button
type="button"
className="btn-icon"
onClick={() => setEditingStepId(step.id)}
title="Edit"
aria-label={`Edit ${step.name}`}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon"
onClick={() => handleDeleteStep(step.id)}
title="Delete"
aria-label={`Delete ${step.name}`}
>
<Trash2 size={14} />
</button>
</div>
</div>
)}
</div>
))}
</div>
<div className="steps-add-buttons">
<button
type="button"
className="btn btn-sm"
onClick={() => handleAddStep("command")}
>
<Plus size={14} />
Add Command Step
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => handleAddStep("ai-prompt")}
>
<Plus size={14} />
Add AI Prompt Step
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { Terminal, Sparkles } from "lucide-react";
import type { AutomationStepType } from "@kb/core";
interface StepTypeBadgeProps {
type: AutomationStepType;
size?: number;
}
export function StepTypeBadge({ type, size = 12 }: StepTypeBadgeProps) {
if (type === "command") {
return (
<span className="step-type-badge step-type-command" title="Command step">
<Terminal size={size} />
<span>Command</span>
</span>
);
}
return (
<span className="step-type-badge step-type-ai-prompt" title="AI Prompt step">
<Sparkles size={size} />
<span>AI Prompt</span>
</span>
);
}

View File

@@ -14,6 +14,7 @@ vi.mock("lucide-react", () => ({
XCircle: () => <span data-testid="icon-x">✗</span>,
ChevronDown: () => <span data-testid="icon-down">▼</span>,
ChevronUp: () => <span data-testid="icon-up">▲</span>,
Layers: () => <span data-testid="icon-layers">≡</span>,
}));
function makeResult(overrides: Partial<AutomationRunResult> = {}): AutomationRunResult {
@@ -207,4 +208,91 @@ describe("ScheduleCard", () => {
expect(screen.getByText(/just now|ago/)).toBeDefined();
});
});
describe("multi-step schedules", () => {
it("shows step count badge when schedule has steps", () => {
const schedule = makeSchedule({
steps: [
{ id: "s1", type: "command", name: "Build", command: "npm run build" },
{ id: "s2", type: "ai-prompt", name: "Review", prompt: "Review code" },
],
});
render(
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
);
expect(screen.getByText("2 steps")).toBeDefined();
});
it("shows singular 'step' for single step", () => {
const schedule = makeSchedule({
steps: [{ id: "s1", type: "command", name: "Build", command: "npm run build" }],
});
render(
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
);
expect(screen.getByText("1 step")).toBeDefined();
});
it("shows command preview for legacy schedules without steps", () => {
const schedule = makeSchedule({ command: "npm update" });
const { container } = render(
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
);
expect(container.querySelector(".schedule-command-preview")?.textContent).toBe("npm update");
});
it("does not show command preview when schedule has steps", () => {
const schedule = makeSchedule({
steps: [{ id: "s1", type: "command", name: "Build", command: "npm run build" }],
});
const { container } = render(
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
);
expect(container.querySelector(".schedule-command-preview")).toBeNull();
});
it("shows step result dots in run history", () => {
const history = [
makeResult({
stepResults: [
{ stepId: "s1", stepName: "Build", stepIndex: 0, success: true, output: "", startedAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:00:01Z" },
{ stepId: "s2", stepName: "Test", stepIndex: 1, success: false, output: "", error: "Tests failed", startedAt: "2026-01-01T00:00:01Z", completedAt: "2026-01-01T00:00:02Z" },
],
}),
];
const schedule = makeSchedule({ runHistory: history });
const { container } = render(
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
);
// Expand the history
fireEvent.click(screen.getByText("Run History (1)"));
const dots = container.querySelectorAll(".step-result-dot");
expect(dots).toHaveLength(2);
expect(dots[0].classList.contains("success")).toBe(true);
expect(dots[1].classList.contains("failure")).toBe(true);
});
it("shows per-step results in expanded run history", () => {
const history = [
makeResult({
stepResults: [
{ stepId: "s1", stepName: "Build", stepIndex: 0, success: true, output: "build ok", startedAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:00:01Z" },
{ stepId: "s2", stepName: "Test", stepIndex: 1, success: false, output: "", error: "Tests failed", startedAt: "2026-01-01T00:00:01Z", completedAt: "2026-01-01T00:00:02Z" },
],
}),
];
const schedule = makeSchedule({ runHistory: history });
render(
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
);
// Expand the history
fireEvent.click(screen.getByText("Run History (1)"));
// Expand the run item
fireEvent.click(screen.getByRole("button", { name: /Run #1/ }));
// Check per-step results
expect(screen.getByText("Build")).toBeDefined();
expect(screen.getByText("Test")).toBeDefined();
expect(screen.getByText("Tests failed")).toBeDefined();
});
});
});

View File

@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ScheduleStepsEditor } from "../ScheduleStepsEditor";
import type { AutomationStep } from "@kb/core";
// Mock @kb/core
vi.mock("@kb/core", () => ({}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
Plus: () => <span data-testid="icon-plus">+</span>,
Trash2: () => <span data-testid="icon-trash">🗑</span>,
ChevronUp: () => <span data-testid="icon-up">▲</span>,
ChevronDown: () => <span data-testid="icon-down">▼</span>,
Pencil: () => <span data-testid="icon-pencil">✎</span>,
GripVertical: () => <span data-testid="icon-grip">≡</span>,
Terminal: () => <span data-testid="icon-terminal">$</span>,
Sparkles: () => <span data-testid="icon-sparkles">✨</span>,
}));
// Mock crypto.randomUUID for deterministic tests
let uuidCounter = 0;
vi.stubGlobal("crypto", {
randomUUID: () => `test-uuid-${++uuidCounter}`,
});
function makeStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
return {
id: `step-${++uuidCounter}`,
type: "command",
name: "Test Step",
command: "echo hello",
...overrides,
};
}
describe("ScheduleStepsEditor", () => {
const onChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
uuidCounter = 0;
});
describe("step addition", () => {
it("renders add buttons for command and AI prompt", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
expect(screen.getByText("Add Command Step")).toBeDefined();
expect(screen.getByText("Add AI Prompt Step")).toBeDefined();
});
it("adds a command step when clicking Add Command Step", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("Add Command Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].type).toBe("command");
expect(newSteps[0].name).toBe("New Command Step");
});
it("adds an AI prompt step when clicking Add AI Prompt Step", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("Add AI Prompt Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].type).toBe("ai-prompt");
expect(newSteps[0].name).toBe("New AI Prompt Step");
});
it("appends to existing steps", () => {
const existing = [makeStep({ name: "Existing" })];
render(<ScheduleStepsEditor steps={existing} onChange={onChange} />);
fireEvent.click(screen.getByText("Add Command Step"));
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(2);
expect(newSteps[0].name).toBe("Existing");
});
});
describe("step deletion", () => {
it("removes a step when delete button is clicked", () => {
const steps = [
makeStep({ id: "s1", name: "First" }),
makeStep({ id: "s2", name: "Second" }),
];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
const deleteButtons = screen.getAllByTitle("Delete");
fireEvent.click(deleteButtons[0]);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].name).toBe("Second");
});
});
describe("step reordering", () => {
it("moves a step up", () => {
const steps = [
makeStep({ id: "s1", name: "First" }),
makeStep({ id: "s2", name: "Second" }),
];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
// Click "Move up" on the second step
const moveUpButtons = screen.getAllByTitle("Move up");
fireEvent.click(moveUpButtons[1]); // second step's move up button
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps[0].name).toBe("Second");
expect(newSteps[1].name).toBe("First");
});
it("moves a step down", () => {
const steps = [
makeStep({ id: "s1", name: "First" }),
makeStep({ id: "s2", name: "Second" }),
];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
// Click "Move down" on the first step
const moveDownButtons = screen.getAllByTitle("Move down");
fireEvent.click(moveDownButtons[0]); // first step's move down button
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps[0].name).toBe("Second");
expect(newSteps[1].name).toBe("First");
});
it("disables Move Up on the first step", () => {
const steps = [makeStep({ id: "s1", name: "Only" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
const moveUpBtn = screen.getByLabelText("Move Only up");
expect(moveUpBtn.hasAttribute("disabled")).toBe(true);
});
it("disables Move Down on the last step", () => {
const steps = [makeStep({ id: "s1", name: "Only" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
const moveDownBtn = screen.getByLabelText("Move Only down");
expect(moveDownBtn.hasAttribute("disabled")).toBe(true);
});
});
describe("step editing", () => {
it("shows step editor when edit button is clicked", () => {
const steps = [makeStep({ id: "s1", name: "Build" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Build"));
// Editor should show form fields
expect(screen.getByLabelText("Step Name")).toBeDefined();
expect(screen.getByText("Save Step")).toBeDefined();
});
it("closes editor on cancel", () => {
const steps = [makeStep({ id: "s1", name: "Build" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Build"));
expect(screen.getByText("Save Step")).toBeDefined();
fireEvent.click(screen.getByText("Cancel"));
// Editor should be closed; step card should be visible again
expect(screen.queryByText("Save Step")).toBeNull();
expect(screen.getByText("Build")).toBeDefined();
});
});
describe("form validation", () => {
it("shows error when step name is empty", () => {
const steps = [makeStep({ id: "s1", name: "Build" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Build"));
// Clear the name
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save Step"));
expect(screen.getByText("Step name is required")).toBeDefined();
expect(onChange).not.toHaveBeenCalled();
});
it("shows error when command step has no command", () => {
const steps = [makeStep({ id: "s1", name: "Build", command: "echo test" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Build"));
// Clear the command
fireEvent.change(screen.getByDisplayValue("echo test"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save Step"));
expect(screen.getByText("Command is required")).toBeDefined();
});
});
describe("empty state", () => {
it("shows empty state message when no steps", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
expect(screen.getByText(/No steps added yet/)).toBeDefined();
});
it("does not show empty state when steps exist", () => {
const steps = [makeStep()];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
expect(screen.queryByText(/No steps added yet/)).toBeNull();
});
});
describe("step display", () => {
it("shows step index numbers", () => {
const steps = [
makeStep({ id: "s1", name: "First" }),
makeStep({ id: "s2", name: "Second" }),
];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
expect(screen.getByText("1")).toBeDefined();
expect(screen.getByText("2")).toBeDefined();
});
it("shows step names", () => {
const steps = [makeStep({ id: "s1", name: "Build project" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
expect(screen.getByText("Build project")).toBeDefined();
});
it("shows continueOnFailure flag", () => {
const steps = [makeStep({ id: "s1", name: "Build", continueOnFailure: true })];
const { container } = render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
const flag = container.querySelector(".step-card-flag");
expect(flag).not.toBeNull();
expect(flag?.textContent).toBe("⚡");
});
it("shows step count in header", () => {
const steps = [makeStep(), makeStep({ id: "s2" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
expect(screen.getByText("Steps (2)")).toBeDefined();
});
});
});

View File

@@ -8784,6 +8784,264 @@ html .column.drag-over * {
text-align: center;
}
/* Schedule card: step count badge */
.schedule-steps-badge {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
}
.schedule-meta-command-preview {
max-width: 200px;
overflow: hidden;
}
.schedule-command-preview {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
/* Step result indicators (dots in run history header) */
.step-results-indicator {
display: inline-flex;
gap: 3px;
align-items: center;
margin: 0 4px;
}
.step-result-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.step-result-dot.success {
background: var(--color-green, #22c55e);
}
.step-result-dot.failure {
background: var(--color-red, #ef4444);
}
/* Per-step results in run history detail */
.schedule-step-results {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
padding: 6px;
border-radius: 4px;
background: var(--bg-secondary);
}
.schedule-step-result {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.schedule-step-result-status {
display: flex;
align-items: center;
}
.schedule-step-result.success .schedule-step-result-status {
color: var(--color-green, #22c55e);
}
.schedule-step-result.failure .schedule-step-result-status {
color: var(--color-red, #ef4444);
}
.schedule-step-result-name {
font-weight: 500;
color: var(--text-primary);
}
.schedule-step-result-error {
color: var(--color-red, #ef4444);
font-size: 10px;
}
/* Step type badges */
.step-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.step-type-command {
color: var(--color-blue, #3b82f6);
background: color-mix(in srgb, var(--color-blue, #3b82f6) 12%, transparent);
}
.step-type-ai-prompt {
color: var(--color-purple, #a855f7);
background: color-mix(in srgb, var(--color-purple, #a855f7) 12%, transparent);
}
/* Steps editor */
.steps-editor {
margin: 8px 0;
}
.steps-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.steps-editor-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.steps-empty-state {
padding: 16px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
border: 1px dashed var(--border);
border-radius: 6px;
margin-bottom: 8px;
}
.steps-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.step-card {
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-primary);
}
.step-card-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
}
.step-card-drag {
color: var(--text-muted);
cursor: grab;
display: flex;
align-items: center;
}
.step-card-index {
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
min-width: 16px;
text-align: center;
}
.step-card-name {
flex: 1;
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.step-card-flag {
font-size: 10px;
}
.step-card-actions {
display: flex;
gap: 2px;
align-items: center;
}
/* Step editor inline form */
.step-editor {
padding: 12px;
}
.step-editor .form-group {
margin-bottom: 8px;
}
.step-editor-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
/* Steps add buttons */
.steps-add-buttons {
display: flex;
gap: 8px;
}
.steps-add-buttons .btn {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Schedule form mode toggle */
.schedule-mode-toggle {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.schedule-mode-btn {
flex: 1;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: none;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.schedule-mode-btn:hover {
background: var(--bg-tertiary, var(--bg-secondary));
}
.schedule-mode-btn.active {
background: var(--accent-color, #3b82f6);
color: white;
}
.form-group-row {
display: flex;
gap: 12px;
}
.form-group-row .form-group {
flex: 1;
}
/* Schedule form within modal */
.schedule-form {
padding: 0;

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) {

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CronRunner } from "./cron-runner.js";
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, Settings } from "@kb/core";
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, AutomationStep, Settings } from "@kb/core";
import { DEFAULT_SETTINGS } from "@kb/core";
import { randomUUID } from "node:crypto";
function createMockSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
return {
@@ -357,4 +358,270 @@ describe("CronRunner", () => {
expect(runner["pollIntervalMs"]).toBe(60000);
});
});
// ── Multi-step execution ──────────────────────────────────────────
describe("multi-step execution", () => {
function makeStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
return {
id: randomUUID(),
type: "command",
name: "Test step",
command: "echo step-output",
...overrides,
};
}
it("legacy single-command mode still works when no steps are defined", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo legacy-mode" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
expect(result.output).toContain("legacy-mode");
expect(result.stepResults).toBeUndefined();
});
it("executes multiple command steps sequentially", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [
makeStep({ name: "Step 1", command: "echo first" }),
makeStep({ name: "Step 2", command: "echo second" }),
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
expect(result.stepResults).toHaveLength(2);
expect(result.stepResults![0].success).toBe(true);
expect(result.stepResults![0].output).toContain("first");
expect(result.stepResults![1].success).toBe(true);
expect(result.stepResults![1].output).toContain("second");
});
it("stops on step failure when continueOnFailure is false", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [
makeStep({ name: "Failing step", command: "exit 1" }),
makeStep({ name: "Should not run", command: "echo should-not-run" }),
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(false);
expect(result.stepResults).toHaveLength(1); // Only the first step ran
expect(result.stepResults![0].success).toBe(false);
expect(result.error).toContain("1 step(s) failed");
expect(result.error).toContain("execution stopped");
});
it("continues after failure when continueOnFailure is true", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [
makeStep({ name: "Failing step", command: "exit 1", continueOnFailure: true }),
makeStep({ name: "Should still run", command: "echo continued" }),
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(false); // Overall failure
expect(result.stepResults).toHaveLength(2); // Both steps ran
expect(result.stepResults![0].success).toBe(false);
expect(result.stepResults![1].success).toBe(true);
expect(result.stepResults![1].output).toContain("continued");
});
it("uses per-step timeout override", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
timeoutMs: 30000, // schedule-level timeout (large)
steps: [
makeStep({ name: "Slow step", command: "sleep 60", timeoutMs: 100 }), // step-level timeout (tiny)
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(false);
expect(result.stepResults).toHaveLength(1);
expect(result.stepResults![0].error).toContain("timed out");
}, 10000);
it("falls back to schedule-level timeout when step has no timeout", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
timeoutMs: 100, // tiny schedule-level timeout
steps: [
makeStep({ name: "Slow step", command: "sleep 60" }), // no step-level timeout
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(false);
expect(result.stepResults![0].error).toContain("timed out");
}, 10000);
it("handles AI prompt step execution (mocked)", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [
makeStep({
type: "ai-prompt",
name: "AI Analysis",
prompt: "Analyze the codebase",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
command: undefined,
}),
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
expect(result.stepResults).toHaveLength(1);
expect(result.stepResults![0].success).toBe(true);
expect(result.stepResults![0].output).toContain("anthropic/claude-sonnet-4-5");
expect(result.stepResults![0].output).toContain("Analyze the codebase");
});
it("fails AI prompt step when no prompt is provided", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [
makeStep({
type: "ai-prompt",
name: "Empty prompt",
prompt: "",
command: undefined,
}),
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(false);
expect(result.stepResults![0].error).toContain("no prompt specified");
});
it("fails command step when no command is provided", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [
makeStep({ name: "Empty command", command: "" }),
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(false);
expect(result.stepResults![0].error).toContain("no command specified");
});
it("aggregates output from all steps with headers", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [
makeStep({ name: "Alpha", command: "echo alpha-output" }),
makeStep({ name: "Beta", command: "echo beta-output" }),
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.output).toContain("Step 1: Alpha");
expect(result.output).toContain("alpha-output");
expect(result.output).toContain("Step 2: Beta");
expect(result.output).toContain("beta-output");
});
it("records run result with stepResults to automation store", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [makeStep({ name: "S1", command: "echo ok" })],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
await runner.executeSchedule(schedule);
expect(automationStore.recordRun).toHaveBeenCalledWith(
schedule.id,
expect.objectContaining({
success: true,
stepResults: expect.arrayContaining([
expect.objectContaining({
stepName: "S1",
success: true,
}),
]),
}),
);
});
it("mixed step types execute correctly", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [
makeStep({ name: "Build", command: "echo build-done" }),
makeStep({
type: "ai-prompt",
name: "Summarize",
prompt: "Summarize results",
command: undefined,
}),
makeStep({ name: "Deploy", command: "echo deployed" }),
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
expect(result.stepResults).toHaveLength(3);
expect(result.stepResults![0].stepName).toBe("Build");
expect(result.stepResults![1].stepName).toBe("Summarize");
expect(result.stepResults![2].stepName).toBe("Deploy");
});
});
});

View File

@@ -2,7 +2,7 @@ import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { TaskStore } from "@kb/core";
import type { AutomationStore } from "@kb/core";
import type { ScheduledTask, AutomationRunResult } from "@kb/core";
import type { ScheduledTask, AutomationRunResult, AutomationStep, AutomationStepResult } from "@kb/core";
import { createLogger } from "./logger.js";
const execAsync = promisify(exec);
@@ -118,53 +118,26 @@ export class CronRunner {
}
/**
* Execute a single schedule's command.
* - Tracks in-flight state to prevent concurrent runs.
* - Enforces timeout and output buffer limits.
* - Records the run result in the automation store.
* Execute a single schedule.
*
* - **Legacy mode**: When `steps` is undefined/empty, execute `command` directly.
* - **Step mode**: When `steps` is present, execute steps sequentially.
*
* Tracks in-flight state to prevent concurrent runs.
* Records the run result in the automation store.
*/
async executeSchedule(schedule: ScheduledTask): Promise<AutomationRunResult> {
this.inFlight.add(schedule.id);
const startedAt = new Date().toISOString();
log.log(`Executing ${schedule.name} (${schedule.id}): ${schedule.command}`);
let result: AutomationRunResult;
try {
const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const { stdout, stderr } = await execAsync(schedule.command, {
timeout: timeoutMs,
maxBuffer: MAX_BUFFER,
shell: "/bin/sh",
});
const output = truncateOutput(stdout, stderr);
result = {
success: true,
output,
startedAt,
completedAt: new Date().toISOString(),
};
log.log(`✓ ${schedule.name} completed (${result.output.length} bytes output)`);
} catch (err: any) {
const stdout = err.stdout ?? "";
const stderr = err.stderr ?? "";
const output = truncateOutput(stdout, stderr);
const errorMessage = err.killed
? `Command timed out after ${(schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s`
: err.message ?? String(err);
result = {
success: false,
output,
error: errorMessage,
startedAt,
completedAt: new Date().toISOString(),
};
log.warn(`✗ ${schedule.name} failed: ${errorMessage}`);
if (schedule.steps && schedule.steps.length > 0) {
result = await this.executeSteps(schedule, startedAt);
} else {
result = await this.executeLegacyCommand(schedule, startedAt);
}
} finally {
this.inFlight.delete(schedule.id);
}
@@ -178,6 +151,245 @@ export class CronRunner {
return result;
}
/**
* Execute a legacy single-command schedule.
*/
private async executeLegacyCommand(
schedule: ScheduledTask,
startedAt: string,
): Promise<AutomationRunResult> {
log.log(`Executing ${schedule.name} (${schedule.id}): ${schedule.command}`);
try {
const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const { stdout, stderr } = await execAsync(schedule.command, {
timeout: timeoutMs,
maxBuffer: MAX_BUFFER,
shell: "/bin/sh",
});
const output = truncateOutput(stdout, stderr);
log.log(`✓ ${schedule.name} completed (${output.length} bytes output)`);
return {
success: true,
output,
startedAt,
completedAt: new Date().toISOString(),
};
} catch (err: any) {
const stdout = err.stdout ?? "";
const stderr = err.stderr ?? "";
const output = truncateOutput(stdout, stderr);
const errorMessage = err.killed
? `Command timed out after ${(schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s`
: err.message ?? String(err);
log.warn(`✗ ${schedule.name} failed: ${errorMessage}`);
return {
success: false,
output,
error: errorMessage,
startedAt,
completedAt: new Date().toISOString(),
};
}
}
/**
* Execute multiple steps sequentially.
* Aggregates per-step results into an overall AutomationRunResult.
*/
private async executeSteps(
schedule: ScheduledTask,
startedAt: string,
): Promise<AutomationRunResult> {
const steps = schedule.steps!;
log.log(`Executing ${schedule.name} (${schedule.id}): ${steps.length} steps`);
const stepResults: AutomationStepResult[] = [];
let overallSuccess = true;
let stoppedEarly = false;
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
log.log(` Step ${i + 1}/${steps.length}: ${step.name} (${step.type})`);
const stepResult = await this.executeStep(schedule, step, i);
stepResults.push(stepResult);
if (!stepResult.success) {
overallSuccess = false;
if (!step.continueOnFailure) {
log.warn(` Step "${step.name}" failed — stopping execution`);
stoppedEarly = true;
break;
}
log.warn(` Step "${step.name}" failed — continuing (continueOnFailure=true)`);
} else {
log.log(` ✓ Step "${step.name}" completed`);
}
}
// Aggregate output from all steps
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}`);
}
const output = truncateOutput(outputParts.join("\n"), "");
// Build error summary
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;
const status = overallSuccess ? "✓" : "✗";
log.log(`${status} ${schedule.name}: ${stepResults.length}/${steps.length} steps executed, ${failedSteps.length} failed`);
return {
success: overallSuccess,
output,
error,
startedAt,
completedAt: new Date().toISOString(),
stepResults,
};
}
/**
* Execute a single automation step.
*/
async executeStep(
schedule: ScheduledTask,
step: AutomationStep,
stepIndex: number,
): Promise<AutomationStepResult> {
const stepStartedAt = new Date().toISOString();
const timeoutMs = step.timeoutMs ?? schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (step.type === "command") {
return this.executeCommandStep(step, stepIndex, timeoutMs, stepStartedAt);
} else if (step.type === "ai-prompt") {
return this.executeAiPromptStep(step, stepIndex, timeoutMs, stepStartedAt);
}
// Unknown step type
return {
stepId: step.id,
stepName: step.name,
stepIndex,
success: false,
output: "",
error: `Unknown step type: "${(step as any).type}"`,
startedAt: stepStartedAt,
completedAt: new Date().toISOString(),
};
}
/**
* Execute a command step using shell execution.
*/
private async executeCommandStep(
step: AutomationStep,
stepIndex: number,
timeoutMs: number,
startedAt: string,
): Promise<AutomationStepResult> {
if (!step.command?.trim()) {
return {
stepId: step.id,
stepName: step.name,
stepIndex,
success: false,
output: "",
error: "Command step has no command specified",
startedAt,
completedAt: new Date().toISOString(),
};
}
try {
const { stdout, stderr } = await execAsync(step.command, {
timeout: timeoutMs,
maxBuffer: MAX_BUFFER,
shell: "/bin/sh",
});
return {
stepId: step.id,
stepName: step.name,
stepIndex,
success: true,
output: truncateOutput(stdout, stderr),
startedAt,
completedAt: new Date().toISOString(),
};
} catch (err: any) {
const stdout = err.stdout ?? "";
const stderr = err.stderr ?? "";
const errorMessage = err.killed
? `Command timed out after ${timeoutMs / 1000}s`
: err.message ?? String(err);
return {
stepId: step.id,
stepName: step.name,
stepIndex,
success: false,
output: truncateOutput(stdout, stderr),
error: errorMessage,
startedAt,
completedAt: new Date().toISOString(),
};
}
}
/**
* Execute an AI prompt step.
* In a full implementation, this would create an agent session and run the prompt.
* For now, we log the prompt and model selection and return a placeholder result.
*/
private async executeAiPromptStep(
step: AutomationStep,
stepIndex: number,
_timeoutMs: number,
startedAt: string,
): Promise<AutomationStepResult> {
if (!step.prompt?.trim()) {
return {
stepId: step.id,
stepName: step.name,
stepIndex,
success: false,
output: "",
error: "AI prompt step has no prompt specified",
startedAt,
completedAt: new Date().toISOString(),
};
}
const model = step.modelProvider && step.modelId
? `${step.modelProvider}/${step.modelId}`
: "default";
log.log(` AI prompt step "${step.name}" using model: ${model}`);
log.log(` Prompt: ${step.prompt.slice(0, 100)}${step.prompt.length > 100 ? "…" : ""}`);
// TODO: Integrate with actual agent session for AI prompt execution
return {
stepId: step.id,
stepName: step.name,
stepIndex,
success: true,
output: `[AI prompt step — model: ${model}]\nPrompt: ${step.prompt}\n\n(AI execution not yet implemented — prompt recorded for future integration)`,
startedAt,
completedAt: new Date().toISOString(),
};
}
}
/** Combine and truncate stdout/stderr to stay within storage limits. */