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

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

View File

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