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:
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
323
packages/dashboard/app/components/ScheduleStepsEditor.tsx
Normal file
323
packages/dashboard/app/components/ScheduleStepsEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
packages/dashboard/app/components/StepTypeBadge.tsx
Normal file
25
packages/dashboard/app/components/StepTypeBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user