diff --git a/ui/src/components/workflows/WorkflowForm.tsx b/ui/src/components/workflows/WorkflowForm.tsx index 505b532b..8026d747 100644 --- a/ui/src/components/workflows/WorkflowForm.tsx +++ b/ui/src/components/workflows/WorkflowForm.tsx @@ -96,7 +96,7 @@ export function WorkflowForm({ if (workflow) { setName(workflow.name); setAgentId(workflow.agent_id); - const src = workflow.source_config; + const src = workflow.trigger_config; if (src?.type === "github_issues") { setOwner(src.owner); setRepo(src.repo); @@ -168,7 +168,7 @@ export function WorkflowForm({ const request: CreateWorkflowRequest = { name: name.trim(), agent_id: agentId, - source_config: { + trigger_config: { type: "github_issues", owner: owner.trim(), repo: repo.trim(), diff --git a/ui/src/components/workflows/WorkflowTable.tsx b/ui/src/components/workflows/WorkflowTable.tsx index 8cf0f944..5a0885c8 100644 --- a/ui/src/components/workflows/WorkflowTable.tsx +++ b/ui/src/components/workflows/WorkflowTable.tsx @@ -35,7 +35,7 @@ export interface WorkflowTableProps { // --------------------------------------------------------------------------- function sourceLabel(workflow: Workflow): string { - const src = workflow.source_config; + const src = workflow.trigger_config; if (!src) return "No source"; if (src.type === "github_issues") { const parts = [`${src.owner}/${src.repo}`]; diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index 08484d48..438a96bb 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -18,33 +18,54 @@ import { DispatchHistory } from "@/components/workflows/DispatchHistory"; import { WorkflowForm } from "@/components/workflows/WorkflowForm"; import { useAgents } from "@/hooks/useAgents"; import { useWorkflowDetail } from "@/hooks/useWorkflows"; -import type { CreateWorkflowRequest } from "@/types/orchestrator"; +import type { CreateWorkflowRequest, TriggerConfig } from "@/types/orchestrator"; +import { getTriggerLabel } from "@/types/orchestrator"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function sourceDetail( - src: - | { - type: string; - owner?: string; - repo?: string; - labels?: string[]; - state?: string; - } - | undefined, -): string { +function sourceDetail(src: TriggerConfig | undefined): string { if (!src) return "No source configured"; - if (src.type === "github_issues") { - const parts: string[] = []; - if (src.owner && src.repo) parts.push(`${src.owner}/${src.repo}`); - if (src.labels && src.labels.length > 0) - parts.push(`Labels: ${src.labels.join(", ")}`); - if (src.state) parts.push(`State: ${src.state}`); - return parts.join(" · "); + switch (src.type) { + case "github_issues": + case "github_pull_requests": { + const parts: string[] = [`${src.owner}/${src.repo}`]; + if (src.labels.length > 0) parts.push(`Labels: ${src.labels.join(", ")}`); + if (src.state) parts.push(`State: ${src.state}`); + return parts.join(" · "); + } + case "cron": + return `Cron: ${src.expression}`; + case "delay": + return `Run at: ${new Date(src.run_at).toLocaleString()}`; + case "webhook": + return `Webhook (${src.source})`; + case "manual": + return "Manual trigger"; + case "linear_issues": { + const parts: string[] = []; + if (src.team_key) parts.push(src.team_key); + if (src.project) parts.push(src.project); + return parts.length > 0 ? `Linear: ${parts.join(" / ")}` : "Linear Issues"; + } + case "agent_lifecycle": + return `Agent lifecycle: ${src.event}`; + case "agent_idle": + return `Agent idle: ${src.idle_seconds}s`; + case "dispatch_result": + return src.source_workflow_id + ? `Dispatch result from ${src.source_workflow_id}` + : "Dispatch result"; + case "composite": + return `Composite (${src.mode.toUpperCase()}, ${src.triggers.length} triggers)`; + case "queue": + return `Queue: ${src.queue_name}`; + case "ask_response": + return src.category ? `Ask response: ${src.category}` : "Ask response"; + default: + return getTriggerLabel((src as TriggerConfig).type); } - return src.type; } function formatDateTime(iso: string): string { @@ -211,7 +232,7 @@ export function WorkflowDetail() {
{ }); }); - describe("edit mode — valid source_config", () => { + describe("edit mode — valid trigger_config", () => { it("populates the name field", async () => { renderForm(validWorkflow); await waitFor(() => { @@ -143,21 +143,21 @@ describe("WorkflowForm", () => { }); }); - describe("edit mode — missing source_config (null guard)", () => { - it("does not throw when source_config is undefined", () => { + describe("edit mode — missing trigger_config (null guard)", () => { + it("does not throw when trigger_config is undefined", () => { // Cast to bypass TypeScript: simulates an API response with missing field const brokenWorkflow = { ...validWorkflow, - source_config: undefined, + trigger_config: undefined, } as unknown as Workflow; expect(() => renderForm(brokenWorkflow)).not.toThrow(); }); - it("renders the dialog without crashing when source_config is undefined", () => { + it("renders the dialog without crashing when trigger_config is undefined", () => { const brokenWorkflow = { ...validWorkflow, - source_config: undefined, + trigger_config: undefined, } as unknown as Workflow; renderForm(brokenWorkflow); @@ -167,10 +167,10 @@ describe("WorkflowForm", () => { ).toBeInTheDocument(); }); - it("leaves GitHub fields at defaults when source_config is undefined", async () => { + it("leaves GitHub fields at defaults when trigger_config is undefined", async () => { const brokenWorkflow = { ...validWorkflow, - source_config: undefined, + trigger_config: undefined, } as unknown as Workflow; renderForm(brokenWorkflow); @@ -182,10 +182,10 @@ describe("WorkflowForm", () => { }); }); - it("does not throw when source_config is null", () => { + it("does not throw when trigger_config is null", () => { const brokenWorkflow = { ...validWorkflow, - source_config: null, + trigger_config: null, } as unknown as Workflow; expect(() => renderForm(brokenWorkflow)).not.toThrow(); diff --git a/ui/src/test/hooks/useWorkflows.test.ts b/ui/src/test/hooks/useWorkflows.test.ts index 6ed4e598..9047d8ce 100644 --- a/ui/src/test/hooks/useWorkflows.test.ts +++ b/ui/src/test/hooks/useWorkflows.test.ts @@ -17,7 +17,7 @@ function makeWorkflow(overrides: Partial = {}): Workflow { id: "wf-1", name: "Test Workflow", agent_id: "agent-1", - source_config: { + trigger_config: { type: "github_issues", owner: "acme", repo: "myrepo", diff --git a/ui/src/types/orchestrator.ts b/ui/src/types/orchestrator.ts index 558e8621..23696042 100644 --- a/ui/src/types/orchestrator.ts +++ b/ui/src/types/orchestrator.ts @@ -166,10 +166,15 @@ export type DispatchStatus = | "failed" | "skipped"; +// --------------------------------------------------------------------------- +// TriggerConfig — covers all 14 backend trigger variants +// --------------------------------------------------------------------------- + /** - * Tagged union for different task source backends. + * Tagged union for all workflow trigger types. + * Mirrors the Rust `TriggerConfig` enum in crates/orchestrator/src/scheduler/types.rs. */ -export type TaskSourceConfig = +export type TriggerConfig = | { type: "github_issues"; owner: string; @@ -183,8 +188,137 @@ export type TaskSourceConfig = repo: string; labels: string[]; state: "open" | "closed" | "merged" | "all"; + } + | { type: "cron"; expression: string } + | { type: "delay"; run_at: string } + | { + type: "webhook"; + secret?: string; + source: "github" | "linear" | "any"; + } + | { type: "manual" } + | { + type: "linear_issues"; + team_key?: string; + project?: string; + status?: string[]; + labels: string[]; + assignee?: string; + } + | { + type: "agent_lifecycle"; + event: "session_start" | "session_end" | "context_clear"; + } + | { type: "agent_idle"; idle_seconds: number } + | { + type: "dispatch_result"; + source_workflow_id?: string; + status?: DispatchStatus; + } + | { + type: "composite"; + mode: "or" | "and"; + triggers: TriggerConfig[]; + correlation_window_secs?: number; + } + | { + type: "queue"; + queue_name: string; + poll_interval_secs?: number; + visibility_timeout_secs?: number; + } + | { + type: "ask_response"; + agent_id?: string; + category?: string; + response_pattern?: string; }; +/** Convenience union of all trigger type string literals */ +export type TriggerType = TriggerConfig["type"]; + +/** Logical grouping of trigger types */ +export type TriggerCategory = "external" | "schedule" | "event" | "internal"; + +// --------------------------------------------------------------------------- +// TriggerConfig helpers +// --------------------------------------------------------------------------- + +/** Human-readable label for a trigger type */ +export function getTriggerLabel(type: TriggerType): string { + const labels: Record = { + github_issues: "GitHub Issues", + github_pull_requests: "GitHub Pull Requests", + cron: "Cron Schedule", + delay: "Delayed Run", + webhook: "Webhook", + manual: "Manual", + linear_issues: "Linear Issues", + agent_lifecycle: "Agent Lifecycle", + agent_idle: "Agent Idle", + dispatch_result: "Dispatch Result", + composite: "Composite", + queue: "Queue", + ask_response: "Ask Response", + }; + return labels[type] ?? type; +} + +/** Logical category a trigger type belongs to */ +export function getTriggerCategory(type: TriggerType): TriggerCategory { + const categories: Record = { + github_issues: "external", + github_pull_requests: "external", + linear_issues: "external", + webhook: "external", + cron: "schedule", + delay: "schedule", + agent_lifecycle: "event", + agent_idle: "event", + dispatch_result: "event", + ask_response: "event", + manual: "internal", + queue: "internal", + composite: "internal", + }; + return categories[type] ?? "internal"; +} + +/** Default (empty/safe) TriggerConfig for a given type */ +export function getDefaultTriggerConfig(type: TriggerType): TriggerConfig { + switch (type) { + case "github_issues": + return { type, owner: "", repo: "", labels: [], state: "open" }; + case "github_pull_requests": + return { type, owner: "", repo: "", labels: [], state: "open" }; + case "cron": + return { type, expression: "0 * * * *" }; + case "delay": + return { type, run_at: new Date().toISOString() }; + case "webhook": + return { type, source: "any" }; + case "manual": + return { type }; + case "linear_issues": + return { type, labels: [] }; + case "agent_lifecycle": + return { type, event: "session_start" }; + case "agent_idle": + return { type, idle_seconds: 300 }; + case "dispatch_result": + return { type }; + case "composite": + return { type, mode: "or", triggers: [] }; + case "queue": + return { type, queue_name: "" }; + case "ask_response": + return { type }; + } +} + +/** @deprecated Use TriggerConfig instead */ +export type TaskSourceConfig = TriggerConfig; + /** * A workflow as returned by the API. * Mirrors the Rust WorkflowResponse type. @@ -193,7 +327,7 @@ export interface Workflow { id: string; name: string; agent_id: string; - source_config: TaskSourceConfig; + trigger_config: TriggerConfig; prompt_template: string; poll_interval_secs: number; enabled: boolean; @@ -235,7 +369,7 @@ export interface Task { export interface CreateWorkflowRequest { name: string; agent_id: string; - source_config: TaskSourceConfig; + trigger_config: TriggerConfig; prompt_template: string; poll_interval_secs: number; enabled: boolean;