mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
[codex] Add workflow UI parity (#250)
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
use nostr::{EventBuilder, JsonUtil, Kind, Tag, ToBech32};
|
||||
use tauri::State;
|
||||
|
||||
use crate::{app_state::AppState, models::IdentityInfo, relay::relay_ws_url};
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::IdentityInfo,
|
||||
relay::{relay_api_base_url, relay_ws_url},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String> {
|
||||
@@ -28,6 +32,11 @@ pub fn get_relay_ws_url() -> String {
|
||||
relay_ws_url()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_relay_http_url() -> String {
|
||||
relay_api_base_url()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sign_event(
|
||||
kind: u16,
|
||||
|
||||
@@ -345,6 +345,7 @@ pub fn run() {
|
||||
get_presence,
|
||||
set_presence,
|
||||
get_relay_ws_url,
|
||||
get_relay_http_url,
|
||||
discover_acp_providers,
|
||||
discover_managed_agent_prereqs,
|
||||
sign_event,
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import {
|
||||
getWorkflowDescription,
|
||||
getWorkflowDisplayStatus,
|
||||
getWorkflowTriggerSummary,
|
||||
} from "./workflowDefinition";
|
||||
|
||||
type WorkflowCardProps = {
|
||||
workflow: Workflow;
|
||||
@@ -52,6 +57,10 @@ export function WorkflowCard({
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: WorkflowCardProps) {
|
||||
const displayStatus = getWorkflowDisplayStatus(workflow);
|
||||
const description = getWorkflowDescription(workflow.definition);
|
||||
const triggerSummary = getWorkflowTriggerSummary(workflow.definition);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50"
|
||||
@@ -72,15 +81,21 @@ export function WorkflowCard({
|
||||
<span className="truncate text-sm font-medium">
|
||||
{workflow.name}
|
||||
</span>
|
||||
<StatusBadge status={workflow.status} />
|
||||
<StatusBadge status={displayStatus} />
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-3 pl-6 text-[11px] text-muted-foreground">
|
||||
{channelName ? <span>{channelName}</span> : null}
|
||||
{triggerSummary ? <span>{triggerSummary}</span> : null}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="mt-2 pl-6 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
getWorkflowDescription,
|
||||
getWorkflowDisplayStatus,
|
||||
getWorkflowTriggerSummary,
|
||||
} from "./workflowDefinition";
|
||||
|
||||
type WorkflowDetailPanelProps = {
|
||||
workflowId: string;
|
||||
@@ -33,6 +38,13 @@ export function WorkflowDetailPanel({
|
||||
? (runs.find((r) => r.id === selectedRunId) ?? null)
|
||||
: null;
|
||||
const approvalsQuery = useRunApprovalsQuery(workflowId, selectedRunId);
|
||||
const workflowDescription = workflow
|
||||
? getWorkflowDescription(workflow.definition)
|
||||
: null;
|
||||
const triggerSummary = workflow
|
||||
? getWorkflowTriggerSummary(workflow.definition)
|
||||
: null;
|
||||
const workflowStatus = workflow ? getWorkflowDisplayStatus(workflow) : null;
|
||||
|
||||
async function handleTrigger() {
|
||||
try {
|
||||
@@ -49,9 +61,24 @@ export function WorkflowDetailPanel({
|
||||
data-testid="workflow-detail-panel"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold">
|
||||
{workflow?.name ?? "Loading..."}
|
||||
</h3>
|
||||
{workflowStatus ? <RunStatusBadge status={workflowStatus} /> : null}
|
||||
</div>
|
||||
{workflowDescription ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{workflowDescription}
|
||||
</p>
|
||||
) : null}
|
||||
{triggerSummary ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{triggerSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{workflow ? (
|
||||
<Button
|
||||
@@ -165,6 +192,9 @@ export function WorkflowDetailPanel({
|
||||
|
||||
function RunStatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
active: "bg-green-500/15 text-green-500",
|
||||
disabled: "bg-muted text-muted-foreground",
|
||||
archived: "bg-amber-500/15 text-amber-500",
|
||||
completed: "bg-green-500/15 text-green-500",
|
||||
failed: "bg-red-500/15 text-red-500",
|
||||
running: "bg-blue-500/15 text-blue-500",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useUpdateWorkflowMutation,
|
||||
} from "@/features/workflows/hooks";
|
||||
import type { Channel, Workflow } from "@/shared/api/types";
|
||||
import { getRelayHttpUrl } from "@/shared/api/tauri";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from "@/shared/ui/dialog";
|
||||
import { ChannelCombobox } from "./ChannelCombobox";
|
||||
import { WorkflowFormBuilder } from "./WorkflowFormBuilder";
|
||||
import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
|
||||
type DialogMode = "create" | "edit" | "duplicate";
|
||||
@@ -74,6 +76,11 @@ export function WorkflowDialog({
|
||||
const [yamlDefinition, setYamlDefinition] = React.useState(() =>
|
||||
getInitialYaml(mode, workflow),
|
||||
);
|
||||
const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{
|
||||
relayHttpUrl: string;
|
||||
webhookSecret: string;
|
||||
workflowId: string;
|
||||
} | null>(null);
|
||||
|
||||
const createMutation = useCreateWorkflowMutation(selectedChannelId);
|
||||
const updateMutation = useUpdateWorkflowMutation(workflow?.id ?? "");
|
||||
@@ -96,6 +103,7 @@ export function WorkflowDialog({
|
||||
: defaultChannelId;
|
||||
setSelectedChannelId(newChannelId);
|
||||
setYamlDefinition(getInitialYaml(mode, workflow));
|
||||
setSavedWebhookInfo(null);
|
||||
resetCreate();
|
||||
resetUpdate();
|
||||
}
|
||||
@@ -124,8 +132,16 @@ export function WorkflowDialog({
|
||||
if (!selectedChannelId || !yamlDefinition.trim()) return;
|
||||
|
||||
try {
|
||||
await mutation.mutateAsync(yamlDefinition);
|
||||
const saved = await mutation.mutateAsync(yamlDefinition);
|
||||
handleOpenChange(false);
|
||||
if (saved.webhookSecret) {
|
||||
const relayHttpUrl = await getRelayHttpUrl();
|
||||
setSavedWebhookInfo({
|
||||
relayHttpUrl,
|
||||
webhookSecret: saved.webhookSecret,
|
||||
workflowId: saved.workflow.id,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// React Query stores the error; keep the dialog open.
|
||||
}
|
||||
@@ -135,6 +151,7 @@ export function WorkflowDialog({
|
||||
const showChannelInfo = mode !== "edit" && channels.length === 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
@@ -206,7 +223,9 @@ export function WorkflowDialog({
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedChannelId || !yamlDefinition.trim() || mutation.isPending
|
||||
!selectedChannelId ||
|
||||
!yamlDefinition.trim() ||
|
||||
mutation.isPending
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
@@ -216,5 +235,20 @@ export function WorkflowDialog({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{savedWebhookInfo ? (
|
||||
<WorkflowWebhookSecretDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setSavedWebhookInfo(null);
|
||||
}
|
||||
}}
|
||||
open
|
||||
relayHttpUrl={savedWebhookInfo.relayHttpUrl}
|
||||
webhookSecret={savedWebhookInfo.webhookSecret}
|
||||
workflowId={savedWebhookInfo.workflowId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Code, Plus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { WorkflowStepCard } from "./WorkflowStepCard";
|
||||
@@ -34,6 +35,7 @@ function TriggerConfigFields({
|
||||
}) {
|
||||
switch (trigger.on) {
|
||||
case "message_posted":
|
||||
case "diff_posted":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-filter">
|
||||
@@ -49,7 +51,7 @@ function TriggerConfigFields({
|
||||
value={trigger.filter ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Evalexpr filter — leave empty to trigger on all messages.
|
||||
Evalexpr filter — leave empty to trigger on all matching events.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -257,6 +259,42 @@ export function WorkflowFormBuilder({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-description">
|
||||
Description (optional)
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[72px] resize-y text-sm"
|
||||
disabled={disabled}
|
||||
id="wf-description"
|
||||
onChange={(event) =>
|
||||
updateFormState({
|
||||
...formState,
|
||||
description: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="What does this workflow do?"
|
||||
value={formState.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/70 px-3 py-2">
|
||||
<Checkbox
|
||||
checked={formState.enabled}
|
||||
disabled={disabled}
|
||||
id="wf-enabled"
|
||||
onCheckedChange={(checked) =>
|
||||
updateFormState({
|
||||
...formState,
|
||||
enabled: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label className="text-sm" htmlFor="wf-enabled">
|
||||
Workflow is enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-type">Trigger</FieldLabel>
|
||||
@@ -308,11 +346,13 @@ export function WorkflowFormBuilder({
|
||||
<div className="space-y-2">
|
||||
{formState.steps.map((step, index) => (
|
||||
<WorkflowStepCard
|
||||
disabled={disabled}
|
||||
index={index}
|
||||
key={step.id}
|
||||
onRemove={() => removeStep(index)}
|
||||
onUpdate={(updated) => updateStep(index, updated)}
|
||||
step={step}
|
||||
triggerType={formState.trigger.on}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,52 @@ import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
|
||||
import { ACTION_LABELS, ACTION_TYPES } from "./workflowFormTypes";
|
||||
import type { ActionType, StepFormState } from "./workflowFormTypes";
|
||||
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
|
||||
import type {
|
||||
ActionType,
|
||||
StepFormState,
|
||||
TriggerType,
|
||||
} from "./workflowFormTypes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step action config fields
|
||||
// ---------------------------------------------------------------------------
|
||||
function BackendSupportHint({ action }: { action: StepFormState["action"] }) {
|
||||
switch (action) {
|
||||
case "send_dm":
|
||||
return (
|
||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
|
||||
Backend note: `send_dm` is not executed yet, so runs fail at this
|
||||
step.
|
||||
</p>
|
||||
);
|
||||
case "set_channel_topic":
|
||||
return (
|
||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
|
||||
Backend note: `set_channel_topic` is not executed yet, so runs fail at
|
||||
this step.
|
||||
</p>
|
||||
);
|
||||
case "request_approval":
|
||||
return (
|
||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
|
||||
Backend note: approval gates still stop runs with WF-08; approval
|
||||
records are not persisted yet.
|
||||
</p>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function StepConfigFields({
|
||||
step,
|
||||
prefix,
|
||||
disabled,
|
||||
triggerType,
|
||||
onUpdate,
|
||||
}: {
|
||||
step: StepFormState;
|
||||
prefix: string;
|
||||
disabled?: boolean;
|
||||
triggerType: TriggerType;
|
||||
onUpdate: (step: StepFormState) => void;
|
||||
}) {
|
||||
switch (step.action) {
|
||||
@@ -27,6 +60,7 @@ function StepConfigFields({
|
||||
<FieldLabel htmlFor={`${prefix}-duration`}>Duration</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-duration`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, duration: event.target.value })
|
||||
@@ -43,6 +77,7 @@ function StepConfigFields({
|
||||
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[60px] resize-y text-xs"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-text`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, text: event.target.value })
|
||||
@@ -57,23 +92,36 @@ function StepConfigFields({
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-channel`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, channel: event.target.value })
|
||||
}
|
||||
placeholder="Channel UUID — leave empty for trigger channel"
|
||||
placeholder="Channel UUID"
|
||||
value={step.channel ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the trigger channel. Webhook runs and manual
|
||||
Trigger runs need an explicit channel override.
|
||||
</p>
|
||||
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
|
||||
<p className="text-xs text-amber-700">
|
||||
This step will fail for webhook-triggered runs until a channel
|
||||
override is set.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "send_dm":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<BackendSupportHint action={step.action} />
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-to`}>To (pubkey)</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-to`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, to: event.target.value })
|
||||
@@ -86,6 +134,7 @@ function StepConfigFields({
|
||||
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[60px] resize-y text-xs"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-text`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, text: event.target.value })
|
||||
@@ -98,11 +147,12 @@ function StepConfigFields({
|
||||
);
|
||||
case "call_webhook":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-url`}>URL</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-url`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, url: event.target.value })
|
||||
@@ -121,6 +171,7 @@ function StepConfigFields({
|
||||
Method (optional)
|
||||
</FieldLabel>
|
||||
<FormSelect
|
||||
disabled={disabled}
|
||||
id={`${prefix}-method`}
|
||||
onChange={(value) => onUpdate({ ...step, method: value })}
|
||||
value={step.method ?? "POST"}
|
||||
@@ -132,10 +183,17 @@ function StepConfigFields({
|
||||
<option value="DELETE">DELETE</option>
|
||||
</FormSelect>
|
||||
</div>
|
||||
<WorkflowWebhookHeadersEditor
|
||||
disabled={disabled}
|
||||
headers={step.headers ?? []}
|
||||
onChange={(headers) => onUpdate({ ...step, headers })}
|
||||
stepId={step.id || prefix}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-body`}>Body (optional)</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[60px] resize-y font-mono text-xs"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-body`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, body: event.target.value })
|
||||
@@ -149,10 +207,12 @@ function StepConfigFields({
|
||||
case "request_approval":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<BackendSupportHint action={step.action} />
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-from`}>From (approver)</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-from`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, from: event.target.value })
|
||||
@@ -164,6 +224,7 @@ function StepConfigFields({
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-message`}>Message</FieldLabel>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
id={`${prefix}-message`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, message: event.target.value })
|
||||
@@ -177,6 +238,7 @@ function StepConfigFields({
|
||||
Timeout (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
id={`${prefix}-timeout`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, timeout: event.target.value })
|
||||
@@ -193,6 +255,7 @@ function StepConfigFields({
|
||||
<FieldLabel htmlFor={`${prefix}-emoji`}>Emoji</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-emoji`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, emoji: event.target.value })
|
||||
@@ -204,9 +267,12 @@ function StepConfigFields({
|
||||
);
|
||||
case "set_channel_topic":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<BackendSupportHint action={step.action} />
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-topic`}>Topic</FieldLabel>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
id={`${prefix}-topic`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, topic: event.target.value })
|
||||
@@ -215,26 +281,27 @@ function StepConfigFields({
|
||||
value={step.topic ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function WorkflowStepCard({
|
||||
index,
|
||||
disabled,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
step,
|
||||
triggerType,
|
||||
}: {
|
||||
index: number;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
onUpdate: (step: StepFormState) => void;
|
||||
step: StepFormState;
|
||||
triggerType: TriggerType;
|
||||
}) {
|
||||
const prefix = `wf-step-${index}`;
|
||||
|
||||
@@ -247,6 +314,7 @@ export function WorkflowStepCard({
|
||||
<Button
|
||||
aria-label="Remove step"
|
||||
className="h-7 w-7"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
size="icon"
|
||||
type="button"
|
||||
@@ -261,15 +329,34 @@ export function WorkflowStepCard({
|
||||
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-id`}
|
||||
onChange={(event) => onUpdate({ ...step, id: event.target.value })}
|
||||
placeholder="unique_step_id"
|
||||
value={step.id}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-name`}>
|
||||
Step name (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
id={`${prefix}-name`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, name: event.target.value })
|
||||
}
|
||||
placeholder="Human-friendly label"
|
||||
value={step.name ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-action`}>Action</FieldLabel>
|
||||
<FormSelect
|
||||
disabled={disabled}
|
||||
id={`${prefix}-action`}
|
||||
onChange={(value) => {
|
||||
const next = { ...step, action: value as ActionType };
|
||||
@@ -287,9 +374,46 @@ export function WorkflowStepCard({
|
||||
))}
|
||||
</FormSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-timeout-secs`}>
|
||||
Timeout seconds (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
id={`${prefix}-timeout-secs`}
|
||||
inputMode="numeric"
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, timeoutSecs: event.target.value })
|
||||
}
|
||||
placeholder="e.g. 300"
|
||||
value={step.timeoutSecs ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepConfigFields onUpdate={onUpdate} prefix={prefix} step={step} />
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-condition`}>
|
||||
Run condition (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-condition`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, condition: event.target.value })
|
||||
}
|
||||
placeholder='e.g. str_contains(trigger_text, "deploy")'
|
||||
value={step.condition ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StepConfigFields
|
||||
disabled={disabled}
|
||||
onUpdate={onUpdate}
|
||||
prefix={prefix}
|
||||
step={step}
|
||||
triggerType={triggerType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
import type { HeaderFormState } from "./workflowFormTypes";
|
||||
|
||||
function updateHeaders(
|
||||
headers: HeaderFormState[],
|
||||
updater: (headers: HeaderFormState[]) => HeaderFormState[],
|
||||
) {
|
||||
return updater(headers);
|
||||
}
|
||||
|
||||
type WorkflowWebhookHeadersEditorProps = {
|
||||
disabled?: boolean;
|
||||
headers: HeaderFormState[];
|
||||
onChange: (headers: HeaderFormState[]) => void;
|
||||
stepId: string;
|
||||
};
|
||||
|
||||
export function WorkflowWebhookHeadersEditor({
|
||||
disabled,
|
||||
headers,
|
||||
onChange,
|
||||
stepId,
|
||||
}: WorkflowWebhookHeadersEditorProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>Headers (optional)</FieldLabel>
|
||||
<Button
|
||||
className="h-7 gap-1 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
updateHeaders(headers, (currentHeaders) => [
|
||||
...currentHeaders,
|
||||
{
|
||||
id: `${stepId}_header_${currentHeaders.length + 1}`,
|
||||
key: "",
|
||||
value: "",
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
{headers.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No custom headers.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{headers.map((header, index) => (
|
||||
<div className="flex items-center gap-2" key={header.id}>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
updateHeaders(headers, (currentHeaders) =>
|
||||
currentHeaders.map((current, currentIndex) =>
|
||||
currentIndex === index
|
||||
? { ...current, key: event.target.value }
|
||||
: current,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Header name"
|
||||
value={header.key}
|
||||
/>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
updateHeaders(headers, (currentHeaders) =>
|
||||
currentHeaders.map((current, currentIndex) =>
|
||||
currentIndex === index
|
||||
? { ...current, value: event.target.value }
|
||||
: current,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Header value"
|
||||
value={header.value}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Remove header"
|
||||
className="h-9 w-9 shrink-0"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
updateHeaders(headers, (currentHeaders) =>
|
||||
currentHeaders.filter(
|
||||
(_, currentIndex) => currentIndex !== index,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CopyButton } from "@/features/agents/ui/CopyButton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
|
||||
type WorkflowWebhookSecretDialogProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
relayHttpUrl: string;
|
||||
webhookSecret: string;
|
||||
workflowId: string;
|
||||
};
|
||||
|
||||
export function WorkflowWebhookSecretDialog({
|
||||
onOpenChange,
|
||||
open,
|
||||
relayHttpUrl,
|
||||
webhookSecret,
|
||||
workflowId,
|
||||
}: WorkflowWebhookSecretDialogProps) {
|
||||
const webhookUrl = `${relayHttpUrl}/api/workflows/${workflowId}/webhook`;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Webhook Ready</DialogTitle>
|
||||
<DialogDescription>
|
||||
This secret is only shown now. If it is lost, re-save the workflow
|
||||
to generate a new one.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Webhook URL
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
|
||||
{webhookUrl}
|
||||
</pre>
|
||||
<CopyButton label="Copy URL" value={webhookUrl} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
`X-Webhook-Secret`
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
|
||||
{webhookSecret}
|
||||
</pre>
|
||||
<CopyButton label="Copy Secret" value={webhookSecret} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
73
desktop/src/features/workflows/ui/workflowDefinition.ts
Normal file
73
desktop/src/features/workflows/ui/workflowDefinition.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { TRIGGER_LABELS } from "./workflowFormTypes";
|
||||
import type { TriggerType } from "./workflowFormTypes";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function getWorkflowEnabled(
|
||||
definition: Record<string, unknown>,
|
||||
): boolean {
|
||||
return definition.enabled !== false;
|
||||
}
|
||||
|
||||
export function getWorkflowDisplayStatus(
|
||||
workflow: Workflow,
|
||||
): Workflow["status"] | "disabled" {
|
||||
if (workflow.status !== "active") {
|
||||
return workflow.status;
|
||||
}
|
||||
|
||||
return getWorkflowEnabled(workflow.definition) ? workflow.status : "disabled";
|
||||
}
|
||||
|
||||
export function getWorkflowDescription(
|
||||
definition: Record<string, unknown>,
|
||||
): string | null {
|
||||
const description = definition.description;
|
||||
return typeof description === "string" && description.trim().length > 0
|
||||
? description.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getWorkflowTriggerSummary(
|
||||
definition: Record<string, unknown>,
|
||||
): string | null {
|
||||
const trigger = asRecord(definition.trigger);
|
||||
if (!trigger) return null;
|
||||
|
||||
const on = trigger.on;
|
||||
if (typeof on !== "string") return null;
|
||||
|
||||
const label = TRIGGER_LABELS[on as TriggerType] ?? on;
|
||||
switch (on) {
|
||||
case "message_posted":
|
||||
case "diff_posted":
|
||||
return typeof trigger.filter === "string" &&
|
||||
trigger.filter.trim().length > 0
|
||||
? `${label} · ${trigger.filter}`
|
||||
: label;
|
||||
case "reaction_added":
|
||||
return typeof trigger.emoji === "string" &&
|
||||
trigger.emoji.trim().length > 0
|
||||
? `${label} · ${trigger.emoji}`
|
||||
: label;
|
||||
case "schedule":
|
||||
if (typeof trigger.cron === "string" && trigger.cron.trim().length > 0) {
|
||||
return `${label} · ${trigger.cron}`;
|
||||
}
|
||||
if (
|
||||
typeof trigger.interval === "string" &&
|
||||
trigger.interval.trim().length > 0
|
||||
) {
|
||||
return `${label} · ${trigger.interval}`;
|
||||
}
|
||||
return label;
|
||||
default:
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { stringify as yamlStringify, parse as yamlParse } from "yaml";
|
||||
export const TRIGGER_TYPES = [
|
||||
"message_posted",
|
||||
"reaction_added",
|
||||
"diff_posted",
|
||||
"webhook",
|
||||
"schedule",
|
||||
] as const;
|
||||
@@ -31,15 +32,25 @@ export type TriggerConfig = {
|
||||
interval?: string;
|
||||
};
|
||||
|
||||
export type HeaderFormState = {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type StepFormState = {
|
||||
id: string;
|
||||
name?: string;
|
||||
action: ActionType;
|
||||
condition?: string;
|
||||
timeoutSecs?: string;
|
||||
duration?: string;
|
||||
text?: string;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: HeaderFormState[];
|
||||
body?: string;
|
||||
emoji?: string;
|
||||
topic?: string;
|
||||
@@ -50,12 +61,16 @@ export type StepFormState = {
|
||||
|
||||
export type WorkflowFormState = {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
trigger: TriggerConfig;
|
||||
steps: StepFormState[];
|
||||
};
|
||||
|
||||
export const DEFAULT_FORM_STATE: WorkflowFormState = {
|
||||
name: "",
|
||||
description: "",
|
||||
enabled: true,
|
||||
trigger: { on: "message_posted" },
|
||||
steps: [],
|
||||
};
|
||||
@@ -63,6 +78,7 @@ export const DEFAULT_FORM_STATE: WorkflowFormState = {
|
||||
export const TRIGGER_LABELS: Record<TriggerType, string> = {
|
||||
message_posted: "Message Posted",
|
||||
reaction_added: "Reaction Added",
|
||||
diff_posted: "Diff Posted",
|
||||
webhook: "Webhook",
|
||||
schedule: "Schedule",
|
||||
};
|
||||
@@ -77,12 +93,55 @@ export const ACTION_LABELS: Record<ActionType, string> = {
|
||||
set_channel_topic: "Set Channel Topic",
|
||||
};
|
||||
|
||||
function toHeaderRows(
|
||||
headers: unknown,
|
||||
stepId: string,
|
||||
): HeaderFormState[] | undefined {
|
||||
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rows = Object.entries(headers).map(([key, value], index) => ({
|
||||
id: `${stepId}_header_${index + 1}`,
|
||||
key,
|
||||
value: typeof value === "string" ? value : String(value),
|
||||
}));
|
||||
|
||||
return rows.length > 0 ? rows : undefined;
|
||||
}
|
||||
|
||||
function headersToRecord(
|
||||
headers: HeaderFormState[] | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) return undefined;
|
||||
|
||||
const entries = headers
|
||||
.map(({ key, value }) => [key.trim(), value] as const)
|
||||
.filter(([key]) => key.length > 0);
|
||||
|
||||
if (entries.length === 0) return undefined;
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
|
||||
if (!timeoutSecs) return undefined;
|
||||
const trimmed = timeoutSecs.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (step.name?.trim()) fields.name = step.name.trim();
|
||||
if (step.condition?.trim()) fields.if = step.condition.trim();
|
||||
const timeoutSecs = parseTimeoutSecs(step.timeoutSecs);
|
||||
if (timeoutSecs !== undefined) fields.timeout_secs = timeoutSecs;
|
||||
|
||||
switch (step.action) {
|
||||
case "delay":
|
||||
if (step.duration) fields.duration = step.duration;
|
||||
@@ -98,6 +157,10 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
|
||||
case "call_webhook":
|
||||
if (step.url) fields.url = step.url;
|
||||
fields.method = step.method || "POST";
|
||||
{
|
||||
const headers = headersToRecord(step.headers);
|
||||
if (headers) fields.headers = headers;
|
||||
}
|
||||
if (step.body) fields.body = step.body;
|
||||
break;
|
||||
case "request_approval":
|
||||
@@ -117,7 +180,11 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
|
||||
|
||||
export function formStateToYaml(state: WorkflowFormState): string {
|
||||
const trigger: Record<string, unknown> = { on: state.trigger.on };
|
||||
if (state.trigger.on === "message_posted" && state.trigger.filter) {
|
||||
if (
|
||||
(state.trigger.on === "message_posted" ||
|
||||
state.trigger.on === "diff_posted") &&
|
||||
state.trigger.filter
|
||||
) {
|
||||
trigger.filter = state.trigger.filter;
|
||||
}
|
||||
if (state.trigger.on === "reaction_added" && state.trigger.emoji) {
|
||||
@@ -134,7 +201,20 @@ export function formStateToYaml(state: WorkflowFormState): string {
|
||||
...actionFieldsForStep(step),
|
||||
}));
|
||||
|
||||
return yamlStringify({ name: state.name, trigger, steps });
|
||||
const workflow: Record<string, unknown> = {
|
||||
name: state.name,
|
||||
trigger,
|
||||
steps,
|
||||
};
|
||||
|
||||
if (state.description.trim()) {
|
||||
workflow.description = state.description.trim();
|
||||
}
|
||||
if (!state.enabled) {
|
||||
workflow.enabled = false;
|
||||
}
|
||||
|
||||
return yamlStringify(workflow);
|
||||
}
|
||||
|
||||
const STEP_ID_PATTERN = /^step_(\d+)$/;
|
||||
@@ -192,13 +272,23 @@ export function yamlToFormState(
|
||||
const steps: StepFormState[] = rawSteps.map(
|
||||
(step: Record<string, unknown>, index: number) => ({
|
||||
id: (step.id as string) ?? `step_${index + 1}`,
|
||||
name: step.name as string | undefined,
|
||||
action: (step.action as ActionType) ?? ACTION_TYPES[0],
|
||||
condition: step.if as string | undefined,
|
||||
timeoutSecs:
|
||||
step.timeout_secs !== undefined
|
||||
? String(step.timeout_secs)
|
||||
: undefined,
|
||||
duration: step.duration as string | undefined,
|
||||
text: step.text as string | undefined,
|
||||
channel: step.channel as string | undefined,
|
||||
to: step.to as string | undefined,
|
||||
url: step.url as string | undefined,
|
||||
method: step.method as string | undefined,
|
||||
headers: toHeaderRows(
|
||||
step.headers,
|
||||
(step.id as string) ?? `step_${index + 1}`,
|
||||
),
|
||||
body: step.body as string | undefined,
|
||||
emoji: step.emoji as string | undefined,
|
||||
topic: step.topic as string | undefined,
|
||||
@@ -210,7 +300,13 @@ export function yamlToFormState(
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: { name: (parsed.name as string) ?? "", trigger, steps },
|
||||
state: {
|
||||
name: (parsed.name as string) ?? "",
|
||||
description: (parsed.description as string) ?? "",
|
||||
enabled: parsed.enabled !== false,
|
||||
trigger,
|
||||
steps,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -522,6 +522,10 @@ export function getRelayWsUrl(): Promise<string> {
|
||||
return invokeTauri<string>("get_relay_ws_url");
|
||||
}
|
||||
|
||||
export function getRelayHttpUrl(): Promise<string> {
|
||||
return invokeTauri<string>("get_relay_http_url");
|
||||
}
|
||||
|
||||
export async function getChannels(): Promise<Channel[]> {
|
||||
const channels = await invokeTauri<RawChannel[]>("get_channels");
|
||||
return channels.map(fromRawChannel);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
Workflow,
|
||||
WorkflowApproval,
|
||||
WorkflowRun,
|
||||
WorkflowSaveResult,
|
||||
TraceEntry,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
@@ -21,6 +22,10 @@ type RawWorkflow = {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type RawWorkflowSaveResponse = RawWorkflow & {
|
||||
webhook_secret?: string | null;
|
||||
};
|
||||
|
||||
type RawTraceEntry = {
|
||||
step_id: string;
|
||||
status: string;
|
||||
@@ -84,6 +89,13 @@ function fromRawWorkflow(raw: RawWorkflow): Workflow {
|
||||
};
|
||||
}
|
||||
|
||||
function fromRawWorkflowSave(raw: RawWorkflowSaveResponse): WorkflowSaveResult {
|
||||
return {
|
||||
workflow: fromRawWorkflow(raw),
|
||||
webhookSecret: raw.webhook_secret ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function fromRawTraceEntry(raw: RawTraceEntry): TraceEntry {
|
||||
return {
|
||||
stepId: raw.step_id,
|
||||
@@ -165,23 +177,23 @@ export async function getWorkflow(workflowId: string): Promise<Workflow> {
|
||||
export async function createWorkflow(
|
||||
channelId: string,
|
||||
yamlDefinition: string,
|
||||
): Promise<Workflow> {
|
||||
const raw = await invokeTauri<RawWorkflow>("create_workflow", {
|
||||
): Promise<WorkflowSaveResult> {
|
||||
const raw = await invokeTauri<RawWorkflowSaveResponse>("create_workflow", {
|
||||
channelId,
|
||||
yamlDefinition,
|
||||
});
|
||||
return fromRawWorkflow(raw);
|
||||
return fromRawWorkflowSave(raw);
|
||||
}
|
||||
|
||||
export async function updateWorkflow(
|
||||
workflowId: string,
|
||||
yamlDefinition: string,
|
||||
): Promise<Workflow> {
|
||||
const raw = await invokeTauri<RawWorkflow>("update_workflow", {
|
||||
): Promise<WorkflowSaveResult> {
|
||||
const raw = await invokeTauri<RawWorkflowSaveResponse>("update_workflow", {
|
||||
workflowId,
|
||||
yamlDefinition,
|
||||
});
|
||||
return fromRawWorkflow(raw);
|
||||
return fromRawWorkflowSave(raw);
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(workflowId: string): Promise<void> {
|
||||
|
||||
@@ -472,6 +472,7 @@ export type {
|
||||
WorkflowApprovalStatus,
|
||||
WorkflowRun,
|
||||
WorkflowRunStatus,
|
||||
WorkflowSaveResult,
|
||||
WorkflowStatus,
|
||||
TraceEntry,
|
||||
TriggerWorkflowResponse,
|
||||
|
||||
@@ -11,6 +11,11 @@ export type Workflow = {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type WorkflowSaveResult = {
|
||||
workflow: Workflow;
|
||||
webhookSecret: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowRunStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { hexToBytes } from "@noble/hashes/utils.js";
|
||||
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
|
||||
import { finalizeEvent } from "nostr-tools/pure";
|
||||
import { parse as yamlParse } from "yaml";
|
||||
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
@@ -1016,6 +1017,16 @@ type MockWorkflow = {
|
||||
const mockWorkflows: MockWorkflow[] = [];
|
||||
let mockWorkflowIdCounter = 0;
|
||||
|
||||
function parseWorkflowDefinition(
|
||||
yamlDefinition: string,
|
||||
): Record<string, unknown> {
|
||||
const parsed = yamlParse(yamlDefinition);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Workflow definition must be a YAML object");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function handleGetChannelWorkflows(args: { channelId: string }) {
|
||||
return mockWorkflows.filter((w) => w.channel_id === args.channelId);
|
||||
}
|
||||
@@ -1032,23 +1043,31 @@ function handleCreateWorkflow(args: {
|
||||
}) {
|
||||
mockWorkflowIdCounter += 1;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Parse the name from the YAML definition (simple extraction)
|
||||
const nameMatch = args.yamlDefinition.match(/^name:\s*(.+)$/m);
|
||||
const name = nameMatch
|
||||
? nameMatch[1].trim()
|
||||
const definition = parseWorkflowDefinition(args.yamlDefinition);
|
||||
const name =
|
||||
typeof definition.name === "string"
|
||||
? definition.name
|
||||
: `workflow_${mockWorkflowIdCounter}`;
|
||||
const workflow: MockWorkflow = {
|
||||
id: `mock-wf-${mockWorkflowIdCounter}`,
|
||||
name,
|
||||
owner_pubkey: MOCK_IDENTITY_PUBKEY,
|
||||
channel_id: args.channelId,
|
||||
definition: { raw: args.yamlDefinition },
|
||||
definition,
|
||||
status: "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
mockWorkflows.push(workflow);
|
||||
return workflow;
|
||||
|
||||
const trigger = definition.trigger as Record<string, unknown> | undefined;
|
||||
return {
|
||||
...workflow,
|
||||
webhook_secret:
|
||||
trigger?.on === "webhook"
|
||||
? `mock-webhook-secret-${mockWorkflowIdCounter}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function handleUpdateWorkflow(args: {
|
||||
@@ -1057,11 +1076,19 @@ function handleUpdateWorkflow(args: {
|
||||
}) {
|
||||
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
||||
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
const nameMatch = args.yamlDefinition.match(/^name:\s*(.+)$/m);
|
||||
if (nameMatch) workflow.name = nameMatch[1].trim();
|
||||
workflow.definition = { raw: args.yamlDefinition };
|
||||
const definition = parseWorkflowDefinition(args.yamlDefinition);
|
||||
if (typeof definition.name === "string") workflow.name = definition.name;
|
||||
workflow.definition = definition;
|
||||
workflow.updated_at = Math.floor(Date.now() / 1000);
|
||||
return workflow;
|
||||
|
||||
const trigger = definition.trigger as Record<string, unknown> | undefined;
|
||||
return {
|
||||
...workflow,
|
||||
webhook_secret:
|
||||
trigger?.on === "webhook"
|
||||
? `mock-webhook-secret-${workflow.id}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function handleDeleteWorkflow(args: { workflowId: string }) {
|
||||
@@ -3421,6 +3448,8 @@ export function maybeInstallE2eTauriMocks() {
|
||||
);
|
||||
case "get_relay_ws_url":
|
||||
return getRelayWsUrl(activeConfig);
|
||||
case "get_relay_http_url":
|
||||
return getRelayHttpUrl(activeConfig);
|
||||
case "discover_acp_providers":
|
||||
return handleDiscoverAcpProviders();
|
||||
case "discover_backend_providers":
|
||||
|
||||
@@ -15,24 +15,50 @@ async function navigateToWorkflows(page: import("@playwright/test").Page) {
|
||||
async function createWorkflow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
options?: {
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
trigger?: string;
|
||||
stepCondition?: string;
|
||||
stepName?: string;
|
||||
stepTimeoutSecs?: string;
|
||||
},
|
||||
) {
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Fill the form — name field
|
||||
await page.getByLabel("Workflow name").fill(name);
|
||||
await dialog.getByLabel("Workflow name").fill(name);
|
||||
if (options?.description) {
|
||||
await dialog.getByLabel("Description (optional)").fill(options.description);
|
||||
}
|
||||
if (options?.enabled === false) {
|
||||
await dialog.getByLabel("Workflow is enabled").click();
|
||||
}
|
||||
if (options?.trigger) {
|
||||
await dialog.getByLabel("Trigger").selectOption(options.trigger);
|
||||
}
|
||||
|
||||
// Add a step
|
||||
await page.getByRole("button", { name: "Add step" }).click();
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
if (options?.stepName) {
|
||||
await dialog.getByLabel("Step name (optional)").fill(options.stepName);
|
||||
}
|
||||
if (options?.stepCondition) {
|
||||
await dialog
|
||||
.getByLabel("Run condition (optional)")
|
||||
.fill(options.stepCondition);
|
||||
}
|
||||
if (options?.stepTimeoutSecs) {
|
||||
await dialog
|
||||
.getByLabel("Timeout seconds (optional)")
|
||||
.fill(options.stepTimeoutSecs);
|
||||
}
|
||||
|
||||
// Submit
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
await dialog.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
// Wait for dialog to close
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Create Workflow" }),
|
||||
).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("navigates to workflows view and shows empty state", async ({ page }) => {
|
||||
@@ -54,6 +80,48 @@ test("creates a workflow via the form builder", async ({ page }) => {
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||||
});
|
||||
|
||||
test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
const workflowName = `diff_workflow_${Date.now()}`;
|
||||
const description = "Watches diff events for src/ changes";
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName, {
|
||||
description,
|
||||
enabled: false,
|
||||
trigger: "diff_posted",
|
||||
stepName: "Notify reviewers",
|
||||
stepCondition: 'str_contains(trigger_text, "src/")',
|
||||
stepTimeoutSecs: "45",
|
||||
});
|
||||
|
||||
const card = page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
await expect(card).toContainText(workflowName);
|
||||
await expect(card).toContainText(description);
|
||||
await expect(card).toContainText("Diff Posted");
|
||||
await expect(card).toContainText("disabled");
|
||||
});
|
||||
|
||||
test("shows the webhook secret dialog after saving a webhook workflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `webhook_workflow_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName, {
|
||||
trigger: "webhook",
|
||||
});
|
||||
|
||||
await expect(page.getByText("Webhook Ready")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy URL" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy Secret" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page.getByText("Webhook Ready")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("edits an existing workflow", async ({ page }) => {
|
||||
const originalName = `edit_test_${Date.now()}`;
|
||||
const updatedName = `${originalName}_updated`;
|
||||
|
||||
Reference in New Issue
Block a user