From 2d51c6beeff1bae82c18eff18bb11e7570c0772a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 13 Aug 2026 22:07:35 -0700 Subject: [PATCH] Limit workflow ID conditions to equality Signed-off-by: Taylor Ho --- .../workflows/ui/WorkflowConditionBuilder.tsx | 22 +++++++++++++++---- .../ui/workflowConditionExpression.test.mjs | 16 ++++++++++++++ .../ui/workflowConditionExpression.ts | 20 +++++++++++++++++ desktop/tests/e2e/workflows.spec.ts | 7 ++++-- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx index aba54ecb8..460f59792 100644 --- a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx @@ -11,8 +11,10 @@ import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; import { buildConditionExpression, conditionFieldsForTrigger, + conditionOperatorsForField, conditionOperatorNeedsValue, CUSTOM_CONDITION_FIELD, + defaultConditionOperatorForField, normalizeWebhookField, parseConditionExpression, } from "./workflowConditionExpression"; @@ -38,7 +40,15 @@ function initialEditorState( triggerType: TriggerType, ): ParsedConditionExpression { const parsed = parseConditionExpression(value, triggerType); - if (parsed) return parsed; + if (parsed) { + const supportedOperators = conditionOperatorsForField(parsed.field); + return supportedOperators.includes(parsed.operator) + ? parsed + : { + ...parsed, + operator: defaultConditionOperatorForField(parsed.field), + }; + } return { field: value.trim() ? CUSTOM_CONDITION_FIELD : "", @@ -154,6 +164,8 @@ export function WorkflowConditionBuilder({ }; const needsValue = conditionOperatorNeedsValue(editor.operator); + const operatorOptions = conditionOperatorsForField(editor.field); + const usesExactMatchOperators = operatorOptions.length === 2; const webhookFieldInvalid = editor.field === "webhook_field" && editor.webhookField.length > 0 && @@ -216,7 +228,7 @@ export function WorkflowConditionBuilder({ } emitEditor({ field: field.value, - operator: "contains", + operator: defaultConditionOperatorForField(field.value), value: "", webhookField: "", }); @@ -264,9 +276,11 @@ export function WorkflowConditionBuilder({ } value={editor.operator} > - {Object.entries(OPERATOR_LABELS).map(([operator, label]) => ( + {operatorOptions.map((operator) => ( ))} diff --git a/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs b/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs index 25b7b348d..c87499e0e 100644 --- a/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs +++ b/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs @@ -4,6 +4,7 @@ import { test } from "node:test"; import { buildConditionExpression, conditionFieldsForTrigger, + conditionOperatorsForField, normalizeWebhookField, parseConditionExpression, } from "./workflowConditionExpression.ts"; @@ -77,6 +78,21 @@ test("shows trigger-relevant fields", () => { assert.equal(conditionFieldsForTrigger("webhook")[0].value, "webhook_field"); }); +test("limits opaque identifiers to equality operators", () => { + for (const field of [ + "trigger_author", + "trigger_channel_id", + "trigger_message_id", + "future_resource_id", + ]) { + assert.deepEqual(conditionOperatorsForField(field), [ + "equals", + "not_equals", + ]); + } + assert.equal(conditionOperatorsForField("trigger_text").length, 8); +}); + test("parses generated conditions back into editor fields", () => { assert.deepEqual( parseConditionExpression( diff --git a/desktop/src/features/workflows/ui/workflowConditionExpression.ts b/desktop/src/features/workflows/ui/workflowConditionExpression.ts index ea8e2750a..6ec956538 100644 --- a/desktop/src/features/workflows/ui/workflowConditionExpression.ts +++ b/desktop/src/features/workflows/ui/workflowConditionExpression.ts @@ -13,6 +13,26 @@ export const CONDITION_OPERATORS = [ export type ConditionOperator = (typeof CONDITION_OPERATORS)[number]; +const EXACT_MATCH_OPERATORS = [ + "equals", + "not_equals", +] as const satisfies readonly ConditionOperator[]; + +/** IDs and pubkeys are opaque identifiers, so partial string matching is not meaningful. */ +export function conditionOperatorsForField( + field: string, +): readonly ConditionOperator[] { + return field === "trigger_author" || field.endsWith("_id") + ? EXACT_MATCH_OPERATORS + : CONDITION_OPERATORS; +} + +export function defaultConditionOperatorForField( + field: string, +): ConditionOperator { + return conditionOperatorsForField(field)[0]; +} + export type ConditionField = { label: string; value: string; diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 158d431a8..35723e1c0 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -336,6 +336,9 @@ test("chooses a trigger channel condition from the live channel list", async ({ const inspector = dialog.getByTestId("workflow-node-inspector"); await inspector.getByRole("button", { name: "Channel ID" }).click(); + const matchOperator = inspector.getByLabel("Match"); + await expect(matchOperator).toHaveValue("equals"); + await expect(matchOperator.locator("option")).toHaveText(["is", "is not"]); const channelCondition = inspector.getByRole("combobox", { name: "Channel ID", }); @@ -356,7 +359,7 @@ test("chooses a trigger channel condition from the live channel list", async ({ await dialog.getByRole("tab", { name: "YAML" }).click(); await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( - /str_contains\(trigger_channel_id, "[0-9a-f-]{36}"\)/, + /trigger_channel_id == "[0-9a-f-]{36}"/, ); }); @@ -448,7 +451,7 @@ test("chooses a trigger author condition from live user search", async ({ await dialog.getByRole("tab", { name: "YAML" }).click(); await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( - /str_contains\(trigger_author,\s+"[0-9a-f]{64}"\)/, + /trigger_author\s+==\s+"[0-9a-f]{64}"/, ); });