feat(FN-1875): merge fusion/fn-1875
This commit is contained in:
@@ -18,7 +18,7 @@ export const AUTOMATION_PRESETS: Record<Exclude<ScheduleType, "custom">, string>
|
||||
// ── Automation Step Types ────────────────────────────────────────────
|
||||
|
||||
/** The type of an automation step. */
|
||||
export type AutomationStepType = "command" | "ai-prompt";
|
||||
export type AutomationStepType = "command" | "ai-prompt" | "create-task";
|
||||
|
||||
/** A single step within a multi-step scheduled task. */
|
||||
export interface AutomationStep {
|
||||
@@ -36,6 +36,12 @@ export interface AutomationStep {
|
||||
modelProvider?: string;
|
||||
/** AI model ID (for ai-prompt steps). */
|
||||
modelId?: string;
|
||||
/** Task title for the created task (for create-task steps). */
|
||||
taskTitle?: string;
|
||||
/** Task description for the created task (for create-task steps). */
|
||||
taskDescription?: string;
|
||||
/** Target column for the created task (for create-task steps). Defaults to "triage". */
|
||||
taskColumn?: string;
|
||||
/** Per-step timeout override in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Whether to continue to the next step if this one fails. Default: false. */
|
||||
|
||||
@@ -25,12 +25,31 @@ function generateStepId(): string {
|
||||
}
|
||||
|
||||
function createEmptyStep(type: AutomationStepType): AutomationStep {
|
||||
if (type === "command") {
|
||||
return {
|
||||
id: generateStepId(),
|
||||
type,
|
||||
name: "New Command Step",
|
||||
command: "",
|
||||
continueOnFailure: false,
|
||||
};
|
||||
}
|
||||
if (type === "ai-prompt") {
|
||||
return {
|
||||
id: generateStepId(),
|
||||
type,
|
||||
name: "New AI Prompt Step",
|
||||
prompt: "",
|
||||
continueOnFailure: false,
|
||||
};
|
||||
}
|
||||
// create-task
|
||||
return {
|
||||
id: generateStepId(),
|
||||
type,
|
||||
name: type === "command" ? "New Command Step" : "New AI Prompt Step",
|
||||
command: type === "command" ? "" : undefined,
|
||||
prompt: type === "ai-prompt" ? "" : undefined,
|
||||
name: "New Create Task Step",
|
||||
taskDescription: "",
|
||||
taskColumn: "triage",
|
||||
continueOnFailure: false,
|
||||
};
|
||||
}
|
||||
@@ -48,6 +67,9 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
|
||||
const [prompt, setPrompt] = useState(step.prompt ?? "");
|
||||
const [modelProvider, setModelProvider] = useState(step.modelProvider ?? "");
|
||||
const [modelId, setModelId] = useState(step.modelId ?? "");
|
||||
const [taskTitle, setTaskTitle] = useState(step.taskTitle ?? "");
|
||||
const [taskDescription, setTaskDescription] = useState(step.taskDescription ?? "");
|
||||
const [taskColumn, setTaskColumn] = useState(step.taskColumn ?? "triage");
|
||||
const [timeoutMs, setTimeoutMs] = useState<number | undefined>(step.timeoutMs);
|
||||
const [continueOnFailure, setContinueOnFailure] = useState(step.continueOnFailure ?? false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
@@ -88,12 +110,19 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
|
||||
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 (type === "create-task" && !taskDescription.trim()) e.taskDescription = "Task description is required";
|
||||
// Model pairing validation for ai-prompt and create-task
|
||||
if ((type === "ai-prompt" || type === "create-task") && (modelProvider || modelId)) {
|
||||
if (!modelProvider || !modelId) {
|
||||
e.modelProvider = "Both model provider and model ID must be set together";
|
||||
}
|
||||
}
|
||||
if (timeoutMs !== undefined && timeoutMs < 1000) {
|
||||
e.timeoutMs = "Timeout must be at least 1 second (1000ms)";
|
||||
}
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}, [name, type, command, prompt, timeoutMs]);
|
||||
}, [name, type, command, prompt, taskDescription, modelProvider, modelId, timeoutMs]);
|
||||
|
||||
// Compute combined model value from separate fields
|
||||
const modelValue = (modelProvider && modelId) ? `${modelProvider}/${modelId}` : "";
|
||||
@@ -114,18 +143,35 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!validate()) return;
|
||||
onSave({
|
||||
|
||||
// Clear fields that don't apply to this step type
|
||||
const baseStep = {
|
||||
...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,
|
||||
taskTitle: type === "create-task" && taskTitle.trim() ? taskTitle.trim() : undefined,
|
||||
taskDescription: type === "create-task" && taskDescription.trim() ? taskDescription.trim() : undefined,
|
||||
taskColumn: type === "create-task" ? taskColumn : undefined,
|
||||
modelProvider: (type === "ai-prompt" || type === "create-task") && modelProvider.trim() ? modelProvider.trim() : undefined,
|
||||
modelId: (type === "ai-prompt" || type === "create-task") && modelId.trim() ? modelId.trim() : undefined,
|
||||
timeoutMs: timeoutMs || undefined,
|
||||
continueOnFailure,
|
||||
});
|
||||
}, [validate, onSave, step, name, type, command, prompt, modelProvider, modelId, timeoutMs, continueOnFailure]);
|
||||
};
|
||||
|
||||
// Clear ai-prompt and create-task specific fields when switching to command
|
||||
if (type !== "ai-prompt") {
|
||||
delete baseStep.prompt;
|
||||
}
|
||||
if (type !== "create-task") {
|
||||
delete baseStep.taskTitle;
|
||||
delete baseStep.taskDescription;
|
||||
delete baseStep.taskColumn;
|
||||
}
|
||||
|
||||
onSave(baseStep as AutomationStep);
|
||||
}, [validate, onSave, step, name, type, command, prompt, taskTitle, taskDescription, taskColumn, modelProvider, modelId, timeoutMs, continueOnFailure]);
|
||||
|
||||
return (
|
||||
<div className="step-editor">
|
||||
@@ -151,6 +197,7 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
|
||||
>
|
||||
<option value="command">Command</option>
|
||||
<option value="ai-prompt">AI Prompt</option>
|
||||
<option value="create-task">Create Task</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -201,6 +248,64 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "create-task" && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor={`step-task-title-${step.id}`}>Task Title (optional)</label>
|
||||
<input
|
||||
id={`step-task-title-${step.id}`}
|
||||
type="text"
|
||||
placeholder="e.g. Review weekly dependencies"
|
||||
value={taskTitle}
|
||||
onChange={(e) => setTaskTitle(e.target.value)}
|
||||
/>
|
||||
<small>Leave blank to auto-summarize from description</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor={`step-task-description-${step.id}`}>Task Description *</label>
|
||||
<textarea
|
||||
id={`step-task-description-${step.id}`}
|
||||
placeholder="e.g. Check all npm dependencies for security vulnerabilities and update outdated packages"
|
||||
value={taskDescription}
|
||||
onChange={(e) => setTaskDescription(e.target.value)}
|
||||
rows={4}
|
||||
aria-invalid={!!errors.taskDescription}
|
||||
/>
|
||||
{errors.taskDescription && <small className="field-error">{errors.taskDescription}</small>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor={`step-task-column-${step.id}`}>Target Column</label>
|
||||
<select
|
||||
id={`step-task-column-${step.id}`}
|
||||
value={taskColumn}
|
||||
onChange={(e) => setTaskColumn(e.target.value)}
|
||||
>
|
||||
<option value="triage">Triage</option>
|
||||
<option value="todo">To Do</option>
|
||||
</select>
|
||||
<small>Column where the new task will be created</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor={`step-task-model-${step.id}`}>Executor Model (optional)</label>
|
||||
<CustomModelDropdown
|
||||
id={`step-task-model-${step.id}`}
|
||||
label="Model"
|
||||
models={models}
|
||||
value={modelValue}
|
||||
onChange={handleModelChange}
|
||||
placeholder="Use default"
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
{modelsError && <small className="field-error">{modelsError}</small>}
|
||||
{errors.modelProvider && <small className="field-error">{errors.modelProvider}</small>}
|
||||
<small>AI model for executing the created task. Uses default if not selected.</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor={`step-timeout-${step.id}`}>Timeout (ms, optional)</label>
|
||||
<input
|
||||
@@ -370,6 +475,14 @@ export function ScheduleStepsEditor({ steps, onChange, onEditingChange }: Schedu
|
||||
<Plus size={14} />
|
||||
Add AI Prompt Step
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleAddStep("create-task")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Create Task Step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Terminal, Sparkles } from "lucide-react";
|
||||
import { Terminal, Sparkles, ListPlus } from "lucide-react";
|
||||
import type { AutomationStepType } from "@fusion/core";
|
||||
|
||||
interface StepTypeBadgeProps {
|
||||
@@ -16,6 +16,15 @@ export function StepTypeBadge({ type, size = 12 }: StepTypeBadgeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "create-task") {
|
||||
return (
|
||||
<span className="step-type-badge step-type-create-task" title="Create Task step">
|
||||
<ListPlus size={size} />
|
||||
<span>Create Task</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="step-type-badge step-type-ai-prompt" title="AI Prompt step">
|
||||
<Sparkles size={size} />
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock("lucide-react", () => ({
|
||||
GripVertical: () => <span data-testid="icon-grip">≡</span>,
|
||||
Terminal: () => <span data-testid="icon-terminal">$</span>,
|
||||
Sparkles: () => <span data-testid="icon-sparkles">✨</span>,
|
||||
ListPlus: () => <span data-testid="icon-list-plus">📋</span>,
|
||||
}));
|
||||
|
||||
// Mock api - provide models synchronously for immediate availability
|
||||
@@ -87,6 +88,7 @@ describe("ScheduleStepsEditor", () => {
|
||||
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
|
||||
expect(screen.getByText("Add Command Step")).toBeDefined();
|
||||
expect(screen.getByText("Add AI Prompt Step")).toBeDefined();
|
||||
expect(screen.getByText("Add Create Task Step")).toBeDefined();
|
||||
});
|
||||
|
||||
it("adds a command step when clicking Add Command Step", () => {
|
||||
@@ -109,6 +111,18 @@ describe("ScheduleStepsEditor", () => {
|
||||
expect(newSteps[0].name).toBe("New AI Prompt Step");
|
||||
});
|
||||
|
||||
it("adds a create-task step when clicking Add Create Task Step", () => {
|
||||
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("Add Create Task Step"));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
|
||||
expect(newSteps).toHaveLength(1);
|
||||
expect(newSteps[0].type).toBe("create-task");
|
||||
expect(newSteps[0].name).toBe("New Create Task Step");
|
||||
expect(newSteps[0].taskDescription).toBe("");
|
||||
expect(newSteps[0].taskColumn).toBe("triage");
|
||||
});
|
||||
|
||||
it("appends to existing steps", () => {
|
||||
const existing = [makeStep({ name: "Existing" })];
|
||||
render(<ScheduleStepsEditor steps={existing} onChange={onChange} />);
|
||||
@@ -249,6 +263,36 @@ describe("ScheduleStepsEditor", () => {
|
||||
fireEvent.click(screen.getByText("Save Step"));
|
||||
expect(screen.getByText("Command is required")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows error when create-task step has no task description", () => {
|
||||
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "Some description" })];
|
||||
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByLabelText("Edit Create Task"));
|
||||
// Clear the task description
|
||||
const descField = screen.getByLabelText("Task Description *");
|
||||
fireEvent.change(descField, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByText("Save Step"));
|
||||
expect(screen.getByText("Task description is required")).toBeDefined();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows saving create-task step with all fields filled", async () => {
|
||||
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "", taskColumn: "triage" })];
|
||||
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByLabelText("Edit Create Task"));
|
||||
|
||||
// Fill in all fields
|
||||
fireEvent.change(screen.getByLabelText("Task Title (optional)"), { target: { value: "Weekly Review" } });
|
||||
fireEvent.change(screen.getByLabelText("Task Description *"), { target: { value: "Check dependencies" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save Step"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const savedStep = onChange.mock.calls[0][0][0] as AutomationStep;
|
||||
expect(savedStep.taskTitle).toBe("Weekly Review");
|
||||
expect(savedStep.taskDescription).toBe("Check dependencies");
|
||||
expect(savedStep.taskColumn).toBe("triage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
@@ -357,6 +401,38 @@ describe("ScheduleStepsEditor", () => {
|
||||
// We can't fully test the React state update in this mock setup,
|
||||
// but we can verify the callback is properly wired
|
||||
});
|
||||
|
||||
it("shows model dropdown for create-task step type", async () => {
|
||||
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "Test description" })];
|
||||
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Edit Create Task"));
|
||||
|
||||
// Wait for models to load and dropdown to appear
|
||||
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
|
||||
|
||||
expect(screen.getByTestId("model-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
it("pre-populates model dropdown when editing create-task step with existing model", async () => {
|
||||
const steps = [makeStep({
|
||||
id: "s1",
|
||||
type: "create-task",
|
||||
name: "Create Task",
|
||||
taskDescription: "Test description",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5"
|
||||
})];
|
||||
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Edit Create Task"));
|
||||
|
||||
// Wait for models to load and dropdown to appear
|
||||
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
|
||||
|
||||
const dropdown = screen.getByTestId("model-dropdown") as HTMLSelectElement;
|
||||
expect(dropdown.getAttribute("data-value")).toBe("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ID generation fallback", () => {
|
||||
|
||||
@@ -1056,4 +1056,177 @@ describe("CronRunner", () => {
|
||||
expect(result.stepResults![0].error).toContain("timed out");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Create-task step execution ─────────────────────────────────────────
|
||||
|
||||
describe("create-task step execution", () => {
|
||||
function makeCreateTaskStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: "create-task",
|
||||
name: "Create task step",
|
||||
taskDescription: "Review dependencies for security vulnerabilities",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("successfully creates a task when taskDescription is provided", async () => {
|
||||
const mockTask = { id: "FN-1234", title: "Review", description: "Review dependencies" };
|
||||
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
||||
const store = createMockStore({} as any);
|
||||
(store as any).createTask = createTaskMock;
|
||||
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [makeCreateTaskStep({ taskDescription: "Check npm packages" })],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepResults).toHaveLength(1);
|
||||
expect(result.stepResults![0].success).toBe(true);
|
||||
expect(result.stepResults![0].output).toContain("Created task FN-1234");
|
||||
expect(createTaskMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns error when taskDescription is missing", async () => {
|
||||
const store = createMockStore();
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [makeCreateTaskStep({ taskDescription: "" })],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stepResults).toHaveLength(1);
|
||||
expect(result.stepResults![0].success).toBe(false);
|
||||
expect(result.stepResults![0].error).toContain("no task description specified");
|
||||
});
|
||||
|
||||
it("passes taskTitle, taskColumn, modelProvider, modelId to store.createTask()", async () => {
|
||||
const mockTask = { id: "FN-5678", title: "Weekly Review", description: "Check packages" };
|
||||
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
||||
const store = createMockStore({} as any);
|
||||
(store as any).createTask = createTaskMock;
|
||||
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [makeCreateTaskStep({
|
||||
taskTitle: "Weekly Review",
|
||||
taskDescription: "Check packages",
|
||||
taskColumn: "todo",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
})],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(createTaskMock).toHaveBeenCalledWith({
|
||||
title: "Weekly Review",
|
||||
description: "Check packages",
|
||||
column: "todo",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults column to triage when taskColumn is not set", async () => {
|
||||
const mockTask = { id: "FN-9999", title: "", description: "Some task" };
|
||||
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
||||
const store = createMockStore({} as any);
|
||||
(store as any).createTask = createTaskMock;
|
||||
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [makeCreateTaskStep({ taskDescription: "Some task" })],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ column: "triage" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles store.createTask() errors gracefully", async () => {
|
||||
const createTaskMock = vi.fn().mockRejectedValue(new Error("Database constraint violation"));
|
||||
const store = createMockStore({} as any);
|
||||
(store as any).createTask = createTaskMock;
|
||||
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [makeCreateTaskStep({ taskDescription: "This will fail" })],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stepResults).toHaveLength(1);
|
||||
expect(result.stepResults![0].success).toBe(false);
|
||||
expect(result.stepResults![0].error).toContain("Database constraint violation");
|
||||
});
|
||||
|
||||
it("trims whitespace from task fields", async () => {
|
||||
const mockTask = { id: "FN-0001", title: "Cleaned", description: "Trimmed" };
|
||||
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
||||
const store = createMockStore({} as any);
|
||||
(store as any).createTask = createTaskMock;
|
||||
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [makeCreateTaskStep({
|
||||
taskTitle: " Cleaned ",
|
||||
taskDescription: " Trimmed ",
|
||||
modelProvider: " anthropic ",
|
||||
modelId: " claude-sonnet-4-5 ",
|
||||
})],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Cleaned",
|
||||
description: "Trimmed",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets title to undefined when taskTitle is not provided", async () => {
|
||||
const mockTask = { id: "FN-0002", title: "", description: "Minimal task" };
|
||||
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
||||
const store = createMockStore({} as any);
|
||||
(store as any).createTask = createTaskMock;
|
||||
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [makeCreateTaskStep({ taskTitle: undefined })],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
// When taskTitle is undefined, title should be undefined in the input
|
||||
const callArg = createTaskMock.mock.calls[0][0];
|
||||
expect(callArg.title).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import type { AutomationStore } from "@fusion/core";
|
||||
import type { ScheduledTask, AutomationRunResult, AutomationStep, AutomationStepResult } from "@fusion/core";
|
||||
import type { ScheduledTask, AutomationRunResult, AutomationStep, AutomationStepResult, Column, TaskCreateInput } from "@fusion/core";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -325,6 +325,8 @@ export class CronRunner {
|
||||
return this.executeCommandStep(step, stepIndex, timeoutMs, stepStartedAt);
|
||||
} else if (step.type === "ai-prompt") {
|
||||
return this.executeAiPromptStep(step, stepIndex, timeoutMs, stepStartedAt);
|
||||
} else if (step.type === "create-task") {
|
||||
return this.executeCreateTaskStep(step, stepIndex, stepStartedAt);
|
||||
}
|
||||
|
||||
// Unknown step type
|
||||
@@ -490,6 +492,70 @@ export class CronRunner {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a create-task step.
|
||||
* Creates a new task in the task board using the configured fields.
|
||||
*/
|
||||
private async executeCreateTaskStep(
|
||||
step: AutomationStep,
|
||||
stepIndex: number,
|
||||
startedAt: string,
|
||||
): Promise<AutomationStepResult> {
|
||||
// Validate that taskDescription is present and non-empty
|
||||
if (!step.taskDescription?.trim()) {
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: false,
|
||||
output: "",
|
||||
error: "Create-task step has no task description specified",
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Build TaskCreateInput from step fields
|
||||
const taskInput: TaskCreateInput = {
|
||||
title: step.taskTitle?.trim() || undefined,
|
||||
description: step.taskDescription.trim(),
|
||||
column: (step.taskColumn as Column) || "triage",
|
||||
modelProvider: step.modelProvider?.trim() || undefined,
|
||||
modelId: step.modelId?.trim() || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const task = await this.store.createTask(taskInput);
|
||||
|
||||
const output = `Created task ${task.id}: ${task.title || task.description.slice(0, 80)}`;
|
||||
log.log(` ✓ Create-task step "${step.name}" created task ${task.id}`);
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: true,
|
||||
output,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = (err as Error).message ?? String(err);
|
||||
log.warn(` ✗ Create-task step "${step.name}" failed: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: false,
|
||||
output: "",
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AI_AUTOMATION_SYSTEM_PROMPT = [
|
||||
|
||||
Reference in New Issue
Block a user