Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ui/src/components/workflows/WorkflowForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/workflows/WorkflowTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`];
Expand Down
63 changes: 42 additions & 21 deletions ui/src/pages/workflows/WorkflowDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -211,7 +232,7 @@ export function WorkflowDetail() {
<dl>
<ConfigRow
label="Source"
value={sourceDetail(workflow.source_config)}
value={sourceDetail(workflow.trigger_config)}
/>
<ConfigRow
label="Poll interval"
Expand Down
28 changes: 14 additions & 14 deletions ui/src/test/components/workflows/WorkflowForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* WorkflowForm tests.
*
* Covers the null-guard fix for source_config: editing a workflow where
* source_config is undefined/null must not throw, and editing one with a
* valid github_issues source_config must populate the form fields correctly.
* Covers the null-guard fix for trigger_config: editing a workflow where
* trigger_config is undefined/null must not throw, and editing one with a
* valid github_issues trigger_config must populate the form fields correctly.
*/

import { render, screen, waitFor } from "@testing-library/react";
Expand Down Expand Up @@ -52,7 +52,7 @@ const validWorkflow: Workflow = {
id: "wf-1",
name: "My Workflow",
agent_id: "agent-1",
source_config: {
trigger_config: {
type: "github_issues",
owner: "geoffjay",
repo: "agentd",
Expand Down Expand Up @@ -105,7 +105,7 @@ describe("WorkflowForm", () => {
});
});

describe("edit mode — valid source_config", () => {
describe("edit mode — valid trigger_config", () => {
it("populates the name field", async () => {
renderForm(validWorkflow);
await waitFor(() => {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion ui/src/test/hooks/useWorkflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function makeWorkflow(overrides: Partial<Workflow> = {}): Workflow {
id: "wf-1",
name: "Test Workflow",
agent_id: "agent-1",
source_config: {
trigger_config: {
type: "github_issues",
owner: "acme",
repo: "myrepo",
Expand Down
142 changes: 138 additions & 4 deletions ui/src/types/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<TriggerType, string> = {
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<TriggerType, TriggerCategory> = {
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.
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down