feat(FN-1875): merge fusion/fn-1875

This commit is contained in:
gsxdsm
2026-04-15 09:45:57 -07:00
parent 7736a805e1
commit afcf2fd12e
6 changed files with 455 additions and 12 deletions

View File

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

View File

@@ -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 = [