diff --git a/studio/docs/specs/react-studio-preview/plan.md b/studio/docs/specs/react-studio-preview/plan.md index bb3eb16509..5125e3fff5 100644 --- a/studio/docs/specs/react-studio-preview/plan.md +++ b/studio/docs/specs/react-studio-preview/plan.md @@ -143,3 +143,14 @@ selection preserves the complete definition. Unit fixtures cover each editor and extension registration. A browser fixture loads the actual ESM entry and exercises editing, local-time conversion, read-only behavior and an extension email hint. The current frontend suite has 110 unit cases and seven browser cases. + + +## Activity settings and retries + +ActivityEditors now includes commit strategy, log persistence, resilience, task and +workflow-activity version tabs. WorkflowInstances includes paged retry history per +activity execution, with details and permission checks. The [Blazor parity checklist](../../../react/docs/activity-settings.md) +records the wire contracts, visibility and verification boundaries. Browser fixtures +exercise the actual production modules; a local reference-backend check verifies +save/reload of each setting and a published reusable activity's paired version IDs. +This does not close the broader partial-parity or release acceptance gates. diff --git a/studio/react/README.md b/studio/react/README.md index 6ad3d3c210..6c92b81d68 100644 --- a/studio/react/README.md +++ b/studio/react/README.md @@ -67,6 +67,7 @@ New to the project? Start with the [Getting started guide](docs/getting-started. | [Getting started](docs/getting-started.md) | Install, run, connect to a server, first sign-in. | | [Configuration](docs/configuration.md) | **Adding servers**, authentication providers, feature flags, theming, environment variables. | | [Modules](docs/modules.md) | What every module does, how to enable it, and how to build a new one. | +| [Activity settings](docs/activity-settings.md) | Commit, log persistence, resilience, task and version settings; instance retry history. | | [Designer](docs/designer.md) | Containers, the quick-add palette, data flow, call context, keyboard shortcuts. | | [Deployment](docs/deployment.md) | Docker image, standalone output, runtime configuration. | | [Troubleshooting](docs/troubleshooting.md) | CORS, hidden modules, sign-in failures, workflows from older Elsa versions. | diff --git a/studio/react/docs/activity-settings.md b/studio/react/docs/activity-settings.md new file mode 100644 index 0000000000..20c3f0fb96 --- /dev/null +++ b/studio/react/docs/activity-settings.md @@ -0,0 +1,57 @@ +# Activity settings and retry history + +The activity properties panel includes the following Blazor-compatible settings. +All controls honor the designer's read-only state. Changes use the designer store, +so normal save, undo and redo retain the complete activity and unrelated plugin data. +Merely opening a tab does not modify a definition. Strategy catalog failures show an +error while preserving local Inherit/Default, clear and expression editing actions; +only available catalog options are offered, and read-only mode still disables edits. + +| Blazor tab | React tab and visibility | Persisted server contract | Verification | +| --- | --- | --- | --- | +| Commit Strategy | Commit strategy; all activities | `customProperties.commitStrategyName`; catalog uses `name`, not `typeName`; clear also removes the legacy PascalCase alias | Browser selection, immutable-update test, real Core save/reload | +| Log | Log; all activities | `customProperties.logPersistenceConfig`: default, internal state, and camelCase input/output rules | Browser strategy/expression changes, legacy migration tests, real Core save/reload | +| Task | Task; `kind: Task` | `customProperties.runAsynchronously` | Browser checkbox/read-only checks, real Core save/reload | +| Resilience | Resilience; `Resilient` descriptor and installed backend feature (catalog failures retain compatibility) | `customProperties.resilienceStrategy`: Identifier/strategyId or Expression/expression | Browser strategy selection, real Core expression save/reload | +| Version | Version; `RootType: WorkflowDefinitionActivity` | Activity `version` and `workflowDefinitionVersionId` change together | Browser catalog eligibility/read-only checks, paired-version unit test and real Core save/reload | +| Instance Retries | Retries in the selected activity's details; resilient descriptor or saved policy, and `resilience/retries:view` permission | `GET resilience/retries/{activityInstanceId}?skip=…&take=…` | Actual viewer browser test with multiple executions, two pages, attempt details and empty history; encoded ID/auth/tenant/cancellation API test | + +The pre-existing asynchronous control remains in Common for other activity kinds. +Additional module-provided activity tabs continue to render through the SDK. + +Log rules select a catalog strategy or an expression returning `LogPersistenceMode` +(`Inherit`, `Include`, `Exclude`; numeric enum values are accepted when reading). +They do not use booleans. Strategy identities come from the server catalog. +Legacy `logPersistenceMode` default/input/output modes are read without mutation and +upgraded to assembly-qualified built-in strategy identities when settings change. +Non-null current rules take precedence. Clearing writes null and removes only the +corresponding legacy entry, because the runtime falls back to legacy values for a +null rule. Unknown properties and unrelated historical rules are retained. + +Workflow activity versions use the `AllVersions` query. A version can be selected +only when its type and version are present in the activity catalog; the current +activity inputs and custom properties survive the change. The list is paginated. Toolbox creation normalizes the server catalog's CLR-named +workflow definition construction fields to the camelCase activity JSON fields, +without retaining conflicting aliases. + +Retry history uses the execution record ID, rather than the graph activity/node ID. +Repeated executions of one node can therefore be inspected separately. Switching +execution resets the page and details; changing page clears the selected attempt. +Queries share the host's authentication, tenant and query scope, cancel on unmount, +and are invalidated with their workflow instance. Loading, API errors, missing +server support, no executions and empty history have explicit states. Opening other +tabs does not fetch retries. + +The browser tests in `tests/activity-settings.spec.ts` load the production ESM +modules through the real ASP.NET host and substitute deterministic API responses. +They cover rendering and HTTP contracts; they do not claim to exercise server retry +execution. The separate reference-backend check creates and publishes two reusable +workflow versions, saves a parent with commit/log/resilience/task settings, reloads +and verifies every setting and version ID, then removes its temporary definitions. + + +To repeat the reference-backend check, set `ELSA_TEST_API_URL`, +`ELSA_TEST_USERNAME` and `ELSA_TEST_PASSWORD` for a disposable development server, +then run `pnpm test:settings-server`. The test needs workflow create, publish, view +and delete permissions and creates temporary published definitions. Credentials +remain in memory; temporary definitions are deleted in its cleanup handler. diff --git a/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/__tests__/activity-settings.test.ts b/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/__tests__/activity-settings.test.ts new file mode 100644 index 0000000000..ea497d13c1 --- /dev/null +++ b/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/__tests__/activity-settings.test.ts @@ -0,0 +1,78 @@ +import { createDesignerStore } from "@elsa-workflows/react-studio-sdk/designer/store"; +import type { ActivityDescriptor } from "@elsa-workflows/react-studio-api-client/types/descriptors"; +import { describe, expect, it } from "vitest"; +import type { Activity } from "@elsa-workflows/react-studio-api-client/types/activities"; +import type { WorkflowDefinitionSummary } from "@elsa-workflows/react-studio-api-client/types/workflow-definitions"; +import { persistenceConfiguration, setCustomProperty, setPersistenceRule, applyActivityVersion } from "../settings/activity-settings"; + +const activity = (customProperties: Record = {}): Activity => ({ id: "child", type: "Example.Child", version: 1, customProperties }); + +describe("per-activity settings", () => { + it("preserves inputs and unrelated custom properties while updating or clearing one setting", () => { + const original = { ...activity({ pluginData: { keep: true }, commitStrategyName: "Existing" }), input: { expression: { type: "Literal", value: "keep" } } }; + const updated = setCustomProperty(original, "commitStrategyName", "Always"); + expect(original.customProperties?.commitStrategyName).toBe("Existing"); + expect(updated.input).toBe(original.input); + expect(updated.customProperties?.pluginData).toBe(original.customProperties?.pluginData); + expect(setCustomProperty(updated, "commitStrategyName", undefined).customProperties).toEqual({ pluginData: { keep: true } }); + }); + + it("reads string and numeric legacy modes, prefers current settings, and does not mutate on read", () => { + const original = activity({ logPersistenceMode: { default: "Exclude", inputs: { old: 1, skip: "Exclude" }, outputs: { result: 2 } }, + logPersistenceConfig: { inputs: { old: { evaluationMode: 1, expression: { type: "JavaScript", value: "true" } } }, future: "keep" } }); + const snapshot = JSON.stringify(original); + expect(persistenceConfiguration(original)).toEqual({ default: { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" }, future: "keep", inputs: { + old: { evaluationMode: 1, expression: { type: "JavaScript", value: "true" } }, skip: { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" }, + }, outputs: { result: { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" } } }); + expect(JSON.stringify(original)).toBe(snapshot); + }); + + it("clears a legacy property without resurrecting its old mode and preserves adjacent rules", () => { + const original = activity({ logPersistenceMode: { inputs: { secret: "Include", message: "Exclude" } }, other: 42 }); + const updated = setPersistenceRule(original, "inputs", "Secret", undefined); + expect(persistenceConfiguration(updated).inputs).toEqual({ secret: null, message: { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" } }); + expect(updated.customProperties?.logPersistenceMode).toEqual({ inputs: { message: "Exclude" } }); + expect(original.customProperties?.logPersistenceMode).toEqual({ inputs: { secret: "Include", message: "Exclude" } }); + expect(updated.customProperties?.other).toBe(42); + expect(() => setPersistenceRule(original, "outputs", undefined, undefined)).toThrow("property name"); + }); + + it("matches the runtime's null fallback and clears legacy defaults explicitly", () => { + const original = activity({ logPersistenceMode: { default: "Exclude", inputs: { message: "Include" } }, + logPersistenceConfig: { default: null, inputs: { message: null } } }); + expect(persistenceConfiguration(original).default?.strategyType).toContain("Strategies.Exclude,"); + expect(persistenceConfiguration(original).inputs?.message?.strategyType).toContain("Strategies.Include,"); + const cleared = setPersistenceRule(original, "default", undefined, undefined); + expect(persistenceConfiguration(cleared).default).toBeNull(); + expect(cleared.customProperties?.logPersistenceMode).toEqual({ inputs: { message: "Include" } }); + }); + + it("retains expression metadata when setting another log section", () => { + const expression = { type: "JavaScript", value: "getVariable('Persist')" }; + const original = activity({ logPersistenceConfig: { default: { evaluationMode: "Expression", expression }, inputs: { password: null } } }); + const updated = setPersistenceRule(original, "internalState", undefined, { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" }); + expect(persistenceConfiguration(updated)).toMatchObject({ default: { expression }, inputs: { password: null }, internalState: { strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" } }); + }); + + it("updates the activity version and definition-version ID together and rejects another definition", () => { + const original = { ...activity(), workflowDefinitionId: "reusable", workflowDefinitionVersionId: "version-one", input: "keep" }; + const version = { id: "version-two", definitionId: "reusable", version: 2 } as WorkflowDefinitionSummary; + expect(applyActivityVersion(original, version)).toMatchObject({ version: 2, workflowDefinitionVersionId: "version-two", input: "keep" }); + expect(original.version).toBe(1); + expect(() => applyActivityVersion(original, { ...version, definitionId: "another" })).toThrow("another workflow"); + }); +}); + + +it("creates toolbox workflow activities with the server's camelCase version fields", () => { + const store = createDesignerStore({ id: "root", type: "Elsa.Flowchart", version: 1, activities: [], connections: [] }); + const descriptor = { typeName: "Example.Reusable", name: "Reusable", version: 2, kind: "Action", inputs: [], outputs: [], ports: [], + customProperties: { RootType: "WorkflowDefinitionActivity" }, + constructionProperties: { WorkflowDefinitionId: "reusable", WorkflowDefinitionVersionId: "version-two", Version: 2, plugin: "keep" } } as unknown as ActivityDescriptor; + const created = store.getState().addActivity(descriptor, { x: 0, y: 0 }); + expect(created).toMatchObject({ version: 2, workflowDefinitionId: "reusable", workflowDefinitionVersionId: "version-two", plugin: "keep" }); + expect(created).not.toHaveProperty("WorkflowDefinitionId"); + expect(created).not.toHaveProperty("WorkflowDefinitionVersionId"); + expect(created).not.toHaveProperty("Version"); + expect(descriptor.constructionProperties).toHaveProperty("WorkflowDefinitionVersionId", "version-two"); +}); diff --git a/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/activity-properties-panel.tsx b/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/activity-properties-panel.tsx index accf4da36a..685331be1a 100644 --- a/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/activity-properties-panel.tsx +++ b/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/activity-properties-panel.tsx @@ -21,6 +21,8 @@ import { activityDisplayText } from "@elsa-workflows/react-studio-sdk/designer/m import { selectableSyntaxes } from "@elsa-workflows/react-studio-sdk/activity-expressions"; import { useUiHintEditor } from "./registry"; import type { UiHintContext } from "./types"; +import { useRemoteFeatures } from "@elsa-workflows/react-studio-ui/runtime/remote-features"; +import { CommitStrategySettings, LogPersistenceSettings, ResilienceSettings, TaskSettings, VersionSettings } from "./settings/settings-tabs"; export interface ActivityPropertiesPanelProps { workflowDefinitionId?: string; @@ -51,6 +53,7 @@ export function ActivityPropertiesPanel({ workflowDefinitionId, variables, workf const readOnly = useDesignerState((s) => s.readOnly); const updateActivity = useDesignerState((s) => s.updateActivity); const extraTabs = useExtensions("activityTabs"); + const features = useRemoteFeatures(); const { items: expressionDescriptors } = useExpressionDescriptors(); const syntaxes = useMemo(() => selectableSyntaxes(expressionDescriptors), [expressionDescriptors]); const [tab, setTab] = useState("input"); @@ -74,6 +77,11 @@ export function ActivityPropertiesPanel({ workflowDefinitionId, variables, workf activities: siblingActivities, }; const dataSummary = dataFlow?.byActivity.get(activity.id); + const isTask = descriptor?.kind === "Task"; + const isWorkflowActivity = descriptor?.customProperties?.RootType === "WorkflowDefinitionActivity"; + const isResilient = descriptor?.customProperties?.Resilient === true && (features.catalog.has("Elsa.Resilience") || features.isError); + const settingsProps = { activity, readOnly, onChange: update }; + const visibleTab = (tab === "task" && !isTask) || (tab === "version" && !isWorkflowActivity) || (tab === "resilience" && !isResilient) ? "input" : tab; const inputs = [...(descriptor?.inputs ?? [])].filter((i) => i.isBrowsable !== false).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); const outputs = (descriptor?.outputs ?? []).filter((o) => o.isBrowsable !== false); @@ -86,11 +94,16 @@ export function ActivityPropertiesPanel({ workflowDefinitionId, variables, workf
{descriptor?.displayName ?? activity.type}
- setTab(String(v))} className="flex min-h-0 flex-1 flex-col"> + setTab(String(v))} className="flex min-h-0 flex-1 flex-col"> Input Output Common + Commit strategy + Log + {isTask && Task} + {isResilient && Resilience} + {isWorkflowActivity && Version} {extraTabs.map((t) => ( {t.label} @@ -133,14 +146,13 @@ export function ActivityPropertiesPanel({ workflowDefinitionId, variables, workf /> )} - - update((a) => ({ ...a, customProperties: { ...a.customProperties, runAsynchronously: c === true } }))} - /> - + {!isTask && } + + + {isTask && } + {isResilient && } + {isWorkflowActivity && } {extraTabs.map((t) => ( ; + outputs?: Record; + [key: string]: unknown; +} +export interface ResilienceConfiguration { + mode: "Identifier" | "Expression" | 0 | 1; + strategyId?: string | null; + expression?: Expression | null; + [key: string]: unknown; +} + +export function setCustomProperty(activity: Activity, name: string, value: unknown): Activity { + const properties = { ...activity.customProperties }; + if (value === undefined) delete properties[name]; + else properties[name] = value; + return { ...activity, customProperties: properties }; +} + +/** Read legacy input/output persistence modes without modifying a definition merely by opening it. */ +export function persistenceConfiguration(activity: Activity): PersistenceConfiguration { + const current = (activity.customProperties?.logPersistenceConfig ?? {}) as PersistenceConfiguration; + const legacy = activity.customProperties?.logPersistenceMode as { default?: string | number; inputs?: Record; outputs?: Record } | undefined; + const upgrade = (old: Record = {}, next: Record = {}) => ({ + ...Object.fromEntries(Object.entries(old).map(([name, mode]) => [name, { evaluationMode: "Strategy" as const, strategyType: legacyStrategy(mode) }])), ...Object.fromEntries(Object.entries(next).filter(([name, rule]) => rule != null || !(name in old))), + }); + return { ...current, ...(current.default == null && legacy?.default !== undefined ? { default: { evaluationMode: "Strategy" as const, strategyType: legacyStrategy(legacy.default) } } : {}), inputs: upgrade(legacy?.inputs, current.inputs), outputs: upgrade(legacy?.outputs, current.outputs) }; +} + +export function setPersistenceRule(activity: Activity, section: "default" | "internalState" | "inputs" | "outputs", + property: string | undefined, rule: PersistenceRule | undefined): Activity { + const current = persistenceConfiguration(activity); + const updated = rule ? activity : clearLegacyRule(activity, section, property); + if (section === "inputs" || section === "outputs") { + if (!property) throw new Error("A property name is required for input/output persistence"); + const name = property.charAt(0).toLowerCase() + property.slice(1); + const next = { ...current[section] }; + // The runtime falls back from null to legacy data, so clear that one + // legacy entry too. Preserve every unrelated legacy rule and custom field. + next[name] = rule ?? null; + return setCustomProperty(updated, "logPersistenceConfig", { ...current, [section]: next }); + } + return setCustomProperty(updated, "logPersistenceConfig", { ...current, [section]: rule ?? null }); +} + +export function applyActivityVersion(activity: Activity, version: WorkflowDefinitionSummary): Activity { + if (activity.workflowDefinitionId !== version.definitionId) throw new Error("The version belongs to another workflow definition"); + return { ...activity, version: version.version, workflowDefinitionVersionId: version.id }; +} + +function legacyStrategy(mode: string | number): string { + const name = typeof mode === "number" ? ["Inherit", "Include", "Exclude"][mode] : mode; + return ["Inherit", "Include", "Exclude"].includes(name) ? `Elsa.Workflows.LogPersistence.Strategies.${name}, Elsa.Workflows.Core` : String(mode); +} + +function clearLegacyRule(activity: Activity, section: "default" | "internalState" | "inputs" | "outputs", property?: string): Activity { + const legacy = activity.customProperties?.logPersistenceMode as Record | undefined; + if (!legacy) return activity; + const next = { ...legacy }; + if ((section === "inputs" || section === "outputs") && property) { + const rules = { ...(legacy[section] as Record | undefined) }; + delete rules[property.charAt(0).toLowerCase() + property.slice(1)]; + next[section] = rules; + } else delete next[section]; + return setCustomProperty(activity, "logPersistenceMode", next); +} diff --git a/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/settings/settings-tabs.tsx b/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/settings/settings-tabs.tsx new file mode 100644 index 0000000000..5980754854 --- /dev/null +++ b/studio/react/dotnet/modules/Elsa.ReactStudio.ActivityEditors/Client/settings/settings-tabs.tsx @@ -0,0 +1,153 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useElsaClient, useQueryScope } from "@elsa-workflows/react-studio-api-client/react"; +import { useActivityCommitStrategies, useLogPersistenceStrategies } from "@elsa-workflows/react-studio-api-client/hooks/descriptors"; +import type { Activity, ActivityInput } from "@elsa-workflows/react-studio-api-client/types/activities"; +import type { ActivityDescriptor, InputDescriptor } from "@elsa-workflows/react-studio-api-client/types/descriptors"; +import { Button } from "@elsa-workflows/react-studio-ui/button"; +import { Checkbox } from "@elsa-workflows/react-studio-ui/checkbox"; +import { Input } from "@elsa-workflows/react-studio-ui/input"; +import { Field } from "@elsa-workflows/react-studio-ui/common/field"; +import { SimpleSelect, type SelectOption } from "@elsa-workflows/react-studio-ui/common/simple-select"; +import { ErrorAlert } from "@elsa-workflows/react-studio-ui/common/error-alert"; +import { useDesigner } from "@elsa-workflows/react-studio-sdk/designer/context"; +import { ExpressionEditor } from "../expression-editor"; +import type { UiHintContext } from "../types"; +import { persistenceConfiguration, setCustomProperty, setPersistenceRule, applyActivityVersion, type PersistenceRule, type ResilienceConfiguration } from "./activity-settings"; + +interface SettingsProps { + activity: Activity; + readOnly: boolean; + onChange: (update: (current: Activity) => Activity) => void; +} +interface ExpressionSettingsProps extends SettingsProps { syntaxes: string[]; context: UiHintContext } + +export function CommitStrategySettings({ activity, readOnly, onChange }: SettingsProps) { + const { items, isLoading, error } = useActivityCommitStrategies(); + const value = (activity.customProperties?.commitStrategyName ?? activity.customProperties?.CommitStrategyName) as string | undefined; + return <> + + {isLoading &&

Loading commit strategies…

} + + ({ value: item.name, label: item.displayName })), value)} + onChange={next => onChange(current => { + // Remove the legacy alias too, so clearing cannot resurrect it. + const updated = setCustomProperty(current, "CommitStrategyName", undefined); + return setCustomProperty(updated, "commitStrategyName", next); + })} /> + + ; +} + +export function TaskSettings({ activity, readOnly, onChange }: SettingsProps) { + return + onChange(current => setCustomProperty(current, "runAsynchronously", checked === true))} /> + ; +} + +export function LogPersistenceSettings({ activity, descriptor, readOnly, onChange, syntaxes, context }: ExpressionSettingsProps & { descriptor?: ActivityDescriptor }) { + const { items, isLoading, error } = useLogPersistenceStrategies(); + const configuration = persistenceConfiguration(activity); + const options = items.map(item => ({ value: item.typeName, label: item.displayName })); + const rule = (label: string, section: "default" | "internalState" | "inputs" | "outputs", property?: string) => { + const name = property ? property.charAt(0).toLowerCase() + property.slice(1) : undefined; + const value = name ? (configuration[section] as Record | undefined)?.[name] : configuration[section] as PersistenceRule | null; + return onChange(current => setPersistenceRule(current, section, property, next))} />; + }; + return <> + + {isLoading &&

Loading log persistence strategies…

} + {rule("Default log persistence", "default")} + {rule("Internal state log persistence", "internalState")} + {descriptor?.inputs.map(input => rule(`Input: ${input.displayName ?? input.name}`, "inputs", input.name))} + {descriptor?.outputs.map(output => rule(`Output: ${output.displayName ?? output.name}`, "outputs", output.name))} + ; +} + +function PersistenceRuleEditor({ label, rule, options, readOnly, syntaxes, context, onChange }: { + label: string; rule?: PersistenceRule | null; options: SelectOption[]; readOnly: boolean; syntaxes: string[]; context: UiHintContext; + onChange: (rule: PersistenceRule | undefined) => void; +}) { + const mode = !rule ? undefined : rule.evaluationMode === "Expression" || rule.evaluationMode === 1 ? "Expression" : "Strategy"; + const descriptor: InputDescriptor = { name: "Persist", typeName: "LogPersistenceMode", defaultSyntax: "Literal" }; + return
{label} + onChange(next ? { ...rule, evaluationMode: next } : undefined)} /> + {mode === "Strategy" && onChange({ ...rule!, strategyType: next ?? null })} />} + {mode === "Expression" && onChange({ ...rule!, expression: next?.expression ?? null })}> + {literal => ({ value, label: value }))} onChange={literal.onChange} />} + } +
; +} + +export function ResilienceSettings({ activity, readOnly, onChange, syntaxes, context }: ExpressionSettingsProps) { + const client = useElsaClient(); + const scope = useQueryScope(); + const query = useQuery({ queryKey: [scope, "resilience", "strategies"], queryFn: ({ signal }) => client.resilience.strategies(signal) }); + const configuration = activity.customProperties?.resilienceStrategy as ResilienceConfiguration | undefined; + const mode = !configuration ? undefined : configuration.mode === "Expression" || configuration.mode === 1 ? "Expression" : "Identifier"; + const disabled = readOnly; + const write = (next: ResilienceConfiguration | undefined) => onChange(current => setCustomProperty(current, "resilienceStrategy", next)); + return <> + + {query.isLoading &&

Loading resilience strategies…

} + + write(next ? { ...configuration, mode: next } : undefined)} /> + + {mode === "Identifier" && + ({ value: item.id, label: item.displayName })), configuration?.strategyId)} + onChange={next => write({ ...configuration!, strategyId: next ?? null })} /> + } + {mode === "Expression" && + write({ ...configuration!, expression: next?.expression ?? null })}> + {literal => literal.onChange(event.target.value)} />} + + } + ; +} + +export function VersionSettings({ activity, readOnly, onChange }: SettingsProps) { + const client = useElsaClient(); + const scope = useQueryScope(); + const { registry } = useDesigner(); + const [page, setPage] = useState(0); + const query = useQuery({ queryKey: [scope, "workflow-activity-versions", activity.workflowDefinitionId, page], + enabled: !!activity.workflowDefinitionId, + queryFn: ({ signal }) => client.workflowDefinitions.list({ definitionIds: [String(activity.workflowDefinitionId)], versionOptions: "AllVersions", orderBy: "Version", orderDirection: "Descending", page, pageSize: 10 }, signal) }); + return <> +

Current activity version: {activity.version}

+ + {!activity.workflowDefinitionId &&

This activity has no workflow definition ID.

} + {query.isLoading &&

Loading versions…

} + {query.data?.items.map(version => { + const available = registry.descriptors.some(descriptor => descriptor.typeName === activity.type && descriptor.version === version.version); + const selected = activity.workflowDefinitionVersionId === version.id; + return
+ Version {version.version}{version.isPublished ? " · Published" : " · Draft"}{selected ? " · Current" : ""}{!available ? " · Unavailable in activity catalog" : ""} + +
; + })} + {query.data && query.data.items.length === 0 &&

No versions found.

} +
+ + +
+ ; +} + +function withCurrent(options: SelectOption[], value?: string | null): SelectOption[] { + return value && !options.some(option => option.value === value) ? [{ value, label: `${value} (current)` }, ...options] : options; +} diff --git a/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowInstances/Client/components/activity-details-pane.tsx b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowInstances/Client/components/activity-details-pane.tsx index ac5e47d723..e2cb56de48 100644 --- a/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowInstances/Client/components/activity-details-pane.tsx +++ b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowInstances/Client/components/activity-details-pane.tsx @@ -9,6 +9,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@elsa-workflows/react- import type { Activity } from "@elsa-workflows/react-studio-api-client/types/activities"; import type { ActivityExecutionRecord, WorkflowExecutionLogRecord } from "@elsa-workflows/react-studio-api-client/types/workflow-instances"; import { activityDisplayText, useDesigner } from "@elsa-workflows/react-studio-sdk/designer/index"; +import { usePermission, Resources, Verbs } from "@elsa-workflows/react-studio-auth/permissions"; +import { RetriesPanel } from "./retries-panel"; import { ExecutionsTable } from "./executions-table"; /** Bottom pane of the viewer: what happened to the selected activity. */ @@ -24,14 +26,16 @@ export function ActivityDetailsPane({ onClose: () => void; }) { const { registry } = useDesigner(); + const canViewRetries = usePermission(Resources.resilienceRetries, Verbs.view); const [tab, setTab] = useState("executions"); const descriptor = registry.get(activity.type, activity.version); + const showRetries = canViewRetries && (descriptor?.customProperties?.Resilient === true || activity.customProperties?.resilienceStrategy != null); const lastEvent = useMemo(() => [...journal].reverse().find((r) => r.activityId === activity.id), [journal, activity.id]); const latest = executions[executions.length - 1]; return (
- setTab(String(v))} className="h-full min-h-0 gap-0"> + setTab(String(v))} className="h-full min-h-0 gap-0">
{activityDisplayText(activity, descriptor)} @@ -45,6 +49,7 @@ export function ActivityDetailsPane({ Executions {executions.length > 0 && {executions.length}} + {showRetries && Retries} + {query.data && {query.data.totalCount} attempts} + +
} + ; +} diff --git a/studio/react/package.json b/studio/react/package.json index 0ecaee5339..d9a6396164 100644 --- a/studio/react/package.json +++ b/studio/react/package.json @@ -11,6 +11,7 @@ "typecheck": "pnpm build:packages && tsc --noEmit", "test": "pnpm build:packages && vitest run", "test:browser": "playwright test", + "test:settings-server": "pnpm build:packages && node scripts/test-settings-roundtrip.mjs", "build:packages": "pnpm --filter \"./packages/*\" run build", "docs:api": "pnpm build:packages && typedoc", "test:packages": "pnpm build:packages && node scripts/test-packed-packages.mjs", diff --git a/studio/react/packages/api-client/README.md b/studio/react/packages/api-client/README.md index 687a9e8b0b..4e68e8b0e7 100644 --- a/studio/react/packages/api-client/README.md +++ b/studio/react/packages/api-client/README.md @@ -25,3 +25,11 @@ does not retry a second 401. Request options support cancellation through Workflow and activity types are exported from the package root and typed subpaths such as `types/workflow-definitions`. Generated JavaScript uses explicit relative ESM extensions so packed consumers do not need source aliases. + + +`client.resilience.strategies(signal)` reads configured strategy IDs and display +names. `client.resilience.retries(activityInstanceId, { skip, take }, signal)` +returns paged retry records; the ID is an activity execution record ID, not a graph +activity ID. Both methods share the HTTP client's current authentication and tenant. +`CommitStrategyDescriptor` models the commit catalogs' `name` field; other strategy +catalogs retain their `typeName` field. diff --git a/studio/react/packages/api-client/src/client.ts b/studio/react/packages/api-client/src/client.ts index 3669233f14..1d74b17062 100644 --- a/studio/react/packages/api-client/src/client.ts +++ b/studio/react/packages/api-client/src/client.ts @@ -1,5 +1,6 @@ import { descriptorsApi } from "./endpoints/descriptors.js"; import { identityApi } from "./endpoints/identity.js"; +import { resilienceApi } from "./endpoints/resilience.js"; import { workflowDefinitionsApi } from "./endpoints/workflow-definitions.js"; import { workflowInstancesApi } from "./endpoints/workflow-instances.js"; import { createHttpClient, type HttpClient, type HttpClientOptions } from "./http.js"; @@ -16,6 +17,7 @@ export function createElsaClient(options: HttpClientOptions) { workflowInstances: workflowInstancesApi(http), descriptors: descriptorsApi(http), identity: identityApi(http), + resilience: resilienceApi(http), }; } diff --git a/studio/react/packages/api-client/src/endpoints/descriptors.ts b/studio/react/packages/api-client/src/endpoints/descriptors.ts index 88e5f7a9ae..3a2a74bf1c 100644 --- a/studio/react/packages/api-client/src/endpoints/descriptors.ts +++ b/studio/react/packages/api-client/src/endpoints/descriptors.ts @@ -2,6 +2,7 @@ import type { HttpClient } from "../http.js"; import type { ListResponse } from "../types/common.js"; import type { ActivityDescriptor, + CommitStrategyDescriptor, ExpressionDescriptor, FeatureDescriptor, IncidentStrategyDescriptor, @@ -31,9 +32,9 @@ export function descriptorsApi(http: HttpClient) { logPersistenceStrategies: (signal?: AbortSignal) => http.get>("descriptors/log-persistence-strategies", { signal }), workflowCommitStrategies: (signal?: AbortSignal) => - http.get>("descriptors/commit-strategies/workflows", { signal }), + http.get>("descriptors/commit-strategies/workflows", { signal }), activityCommitStrategies: (signal?: AbortSignal) => - http.get>("descriptors/commit-strategies/activities", { signal }), + http.get>("descriptors/commit-strategies/activities", { signal }), installedFeatures: (signal?: AbortSignal) => http.get>("features/installed", { signal }), packageVersion: (signal?: AbortSignal) => http.get<{ packageVersion: string }>("package/version", { signal }), diff --git a/studio/react/packages/api-client/src/endpoints/resilience.test.ts b/studio/react/packages/api-client/src/endpoints/resilience.test.ts new file mode 100644 index 0000000000..108b73e244 --- /dev/null +++ b/studio/react/packages/api-client/src/endpoints/resilience.test.ts @@ -0,0 +1,17 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createElsaClient } from "../client.js"; + +afterEach(() => vi.unstubAllGlobals()); + +it("queries retry attempts by encoded execution ID with paging, current auth/tenant, and cancellation", async () => { + const fetcher = vi.fn(async () => Response.json({ items: [], totalCount: 0 })); + vi.stubGlobal("fetch", fetcher); + const client = createElsaClient({ baseUrl: "https://elsa.example.invalid/api", tenantId: "tenant-a", getAuthHeaders: () => ({ Authorization: "Bearer test" }) }); + const controller = new AbortController(); + await client.resilience.retries("execution/a b", { skip: 10, take: 10 }, controller.signal); + expect(fetcher).toHaveBeenCalledWith("https://elsa.example.invalid/api/resilience/retries/execution%2Fa%20b?skip=10&take=10", expect.objectContaining({ + signal: controller.signal, headers: { Accept: "application/json", Authorization: "Bearer test", "X-Tenant-Id": "tenant-a" }, + })); + await client.resilience.strategies(controller.signal); + expect(fetcher).toHaveBeenLastCalledWith("https://elsa.example.invalid/api/resilience/strategies", expect.objectContaining({ signal: controller.signal })); +}); diff --git a/studio/react/packages/api-client/src/endpoints/resilience.ts b/studio/react/packages/api-client/src/endpoints/resilience.ts new file mode 100644 index 0000000000..722860f1d5 --- /dev/null +++ b/studio/react/packages/api-client/src/endpoints/resilience.ts @@ -0,0 +1,11 @@ +import type { HttpClient } from "../http.js"; +import type { ListResponse, PagedList } from "../types/common.js"; +import type { ResilienceStrategy, RetryAttemptRecord } from "../types/resilience.js"; + +export function resilienceApi(http: HttpClient) { + return { + strategies: (signal?: AbortSignal) => http.get>("resilience/strategies", { signal }), + retries: (activityInstanceId: string, range: { skip?: number; take?: number } = {}, signal?: AbortSignal) => + http.get>(`resilience/retries/${encodeURIComponent(activityInstanceId)}`, { query: range, signal }), + }; +} diff --git a/studio/react/packages/api-client/src/index.ts b/studio/react/packages/api-client/src/index.ts index a2d35b5102..0c7e23b332 100644 --- a/studio/react/packages/api-client/src/index.ts +++ b/studio/react/packages/api-client/src/index.ts @@ -7,3 +7,4 @@ export type * from "./types/common.js"; export type * from "./types/descriptors.js"; export type * from "./types/workflow-definitions.js"; export type * from "./types/workflow-instances.js"; +export type * from "./types/resilience.js"; diff --git a/studio/react/packages/api-client/src/types/descriptors.ts b/studio/react/packages/api-client/src/types/descriptors.ts index ca2f2781ea..07c6c7ae75 100644 --- a/studio/react/packages/api-client/src/types/descriptors.ts +++ b/studio/react/packages/api-client/src/types/descriptors.ts @@ -99,6 +99,13 @@ export interface IncidentStrategyDescriptor { description?: string; } +/** Named strategy returned by the workflow/activity commit-strategy catalogs. */ +export interface CommitStrategyDescriptor { + name: string; + displayName: string; + description?: string; +} + export interface WorkflowActivationStrategyDescriptor { typeName: string; displayName: string; diff --git a/studio/react/packages/api-client/src/types/resilience.ts b/studio/react/packages/api-client/src/types/resilience.ts new file mode 100644 index 0000000000..340d5602fe --- /dev/null +++ b/studio/react/packages/api-client/src/types/resilience.ts @@ -0,0 +1,18 @@ +/** A configured server strategy; additional settings depend on its $type. */ +export interface ResilienceStrategy { + id: string; + displayName: string; + $type?: string; + [key: string]: unknown; +} + +/** Persisted retry history, keyed by the activity execution instance ID. */ +export interface RetryAttemptRecord { + id: string; + activityInstanceId: string; + activityId: string; + workflowInstanceId: string; + attemptNumber: number; + retryDelay: string; + details: Record; +} diff --git a/studio/react/packages/auth/src/permissions.ts b/studio/react/packages/auth/src/permissions.ts index 0ac8b50a30..740a342828 100644 --- a/studio/react/packages/auth/src/permissions.ts +++ b/studio/react/packages/auth/src/permissions.ts @@ -71,6 +71,7 @@ export const Resources = { workflowInstances: "workflows/instances", workflowRuntime: "workflows/runtime", workflowTests: "workflows/tests", + resilienceRetries: "resilience/retries", alterations: "alterations", labels: "labels", secrets: "secrets", diff --git a/studio/react/packages/sdk/src/designer/store.ts b/studio/react/packages/sdk/src/designer/store.ts index 74be576968..eaa421e7b1 100644 --- a/studio/react/packages/sdk/src/designer/store.ts +++ b/studio/react/packages/sdk/src/designer/store.ts @@ -169,9 +169,19 @@ export function createDesignerStore(initialRoot: Activity, readOnly = false): De const buildActivity = (descriptor: ActivityDescriptor, parentNodeId: string | undefined, extra?: Partial, position?: { x: number; y: number }): Activity => { const id = newActivityId(); const container = get().container(); + const construction = { ...descriptor.constructionProperties }; + if (descriptor.customProperties?.RootType === "WorkflowDefinitionActivity") { + // The server catalog uses CLR property names. Activity JSON uses camelCase; + // retaining both spellings can leave an old definition-version ID behind. + construction.workflowDefinitionId ??= construction.WorkflowDefinitionId; + construction.workflowDefinitionVersionId ??= construction.WorkflowDefinitionVersionId; + delete construction.WorkflowDefinitionId; + delete construction.WorkflowDefinitionVersionId; + delete construction.Version; + } const base: Activity = { ...(emptyContainer(descriptor.typeName, parentNodeId) ?? {}), - ...descriptor.constructionProperties, + ...construction, id, nodeId: childNodeId(parentNodeId, id), type: descriptor.typeName, diff --git a/studio/react/packages/ui/src/common/simple-select.tsx b/studio/react/packages/ui/src/common/simple-select.tsx index bc6235ef28..63d28444d8 100644 --- a/studio/react/packages/ui/src/common/simple-select.tsx +++ b/studio/react/packages/ui/src/common/simple-select.tsx @@ -22,6 +22,8 @@ export function SimpleSelect({ size = "default", disabled, allowEmpty, + id, + "aria-label": ariaLabel, }: { value: T | undefined | null; onChange: (value: T | undefined) => void; @@ -32,6 +34,8 @@ export function SimpleSelect({ disabled?: boolean; /** Adds a "(none)" entry that clears the value. */ allowEmpty?: boolean; + id?: string; + "aria-label"?: string; }) { const EMPTY = "__none__"; // The empty entry reads as the placeholder ("Incidents: any", "Not captured"…) rather than a bare "(none)". @@ -44,7 +48,7 @@ export function SimpleSelect({ items={items} disabled={disabled} > - + diff --git a/studio/react/scripts/test-settings-roundtrip.mjs b/studio/react/scripts/test-settings-roundtrip.mjs new file mode 100644 index 0000000000..b4e0bb52c2 --- /dev/null +++ b/studio/react/scripts/test-settings-roundtrip.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { createDesignerStore } from '../packages/sdk/dist/designer/store.js'; +import { createElsaClient } from '../packages/api-client/dist/index.js'; +const { ELSA_TEST_API_URL: baseUrl, ELSA_TEST_USERNAME: username, ELSA_TEST_PASSWORD: password } = process.env; +if (!baseUrl || !username || !password) throw new Error('Set ELSA_TEST_API_URL, ELSA_TEST_USERNAME and ELSA_TEST_PASSWORD for a disposable development server.'); +const response = await fetch(`${baseUrl}/identity/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); +if (!response.ok) throw new Error(`Development server login failed: ${response.status}`); +const login = await response.json(); +assert(login.isAuthenticated && login.accessToken, 'Development server authentication failed'); +const client = createElsaClient({ baseUrl, getAuthHeaders: () => ({ Authorization: `Bearer ${login.accessToken}` }) }); +const cleanup = []; +let primaryError; +try { + const name = `SettingsRoundtrip${Date.now()}`; + const model = { name, variables: [], inputs: [], outputs: [], outcomes: [], customProperties: {}, options: { usableAsActivity: true }, root: { id: 'child-root', type: 'Elsa.Sequence', version: 1, activities: [] } }; + const first = (await client.workflowDefinitions.save({ model, publish: true })).workflowDefinition; + cleanup.push(first.definitionId); + const second = (await client.workflowDefinitions.save({ model: { ...first, description: 'Second version' }, publish: true })).workflowDefinition; + assert.equal(second.version, 2); + const descriptors = await client.descriptors.activities(true); + const descriptor = descriptors.items.find(item => item.customProperties?.WorkflowDefinitionId === first.definitionId && item.version === 2); + assert(descriptor, 'Reusable workflow descriptor version 2 missing'); + const commitStrategies = await client.descriptors.activityCommitStrategies(); + assert(commitStrategies.items.length > 0, 'Commit strategy catalog is empty'); + const logStrategies = await client.descriptors.logPersistenceStrategies(); + assert(logStrategies.items.length > 0, 'Log persistence catalog is empty'); + const customProperties = { commitStrategyName: commitStrategies.items[0].name, runAsynchronously: true, + logPersistenceConfig: { default: { evaluationMode: 'Strategy', strategyType: logStrategies.items[0].typeName }, internalState: { evaluationMode: 'Expression', expression: { type: 'Literal', value: 'Exclude' } }, inputs: { message: { evaluationMode: 'Strategy', strategyType: 'Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core' } }, outputs: { result: null } }, + resilienceStrategy: { mode: 'Expression', expression: { type: 'JavaScript', value: '"Default"' } }, pluginData: { keep: true } }; + const store = createDesignerStore({ id: 'parent-root', type: 'Elsa.Flowchart', version: 1, activities: [], connections: [] }); + const child = store.getState().addActivity(descriptor, { x: 0, y: 0 }, { customProperties }); + assert.equal(child.workflowDefinitionId, first.definitionId); + assert.equal(child.workflowDefinitionVersionId, second.id); + const parent = (await client.workflowDefinitions.save({ model: { ...model, name: `${name}Parent`, options: {}, root: { id: 'parent-root', type: 'Elsa.Flowchart', version: 1, activities: [child], connections: [] } } })).workflowDefinition; + cleanup.push(parent.definitionId); + const loaded = await client.workflowDefinitions.getByDefinitionId(parent.definitionId); + const saved = loaded.root.activities.find(item => item.id === child.id); + for (const [key, value] of Object.entries(customProperties)) assert.deepEqual(saved.customProperties[key], value); + assert.equal(saved.version, 2); + assert.equal(saved.workflowDefinitionVersionId, second.id); + const versions = await client.workflowDefinitions.list({ definitionIds: [first.definitionId], versionOptions: 'AllVersions', page: 0, pageSize: 10 }); + assert(versions.items.some(item => item.id === second.id && item.version === 2)); + console.log('PASS: actual Core API round-trips commit, logging strategy/expression, resilience expression, task mode, custom data, and the reusable activity version pair'); +} catch (error) { + primaryError = error; + throw error; +} finally { + const results = await Promise.allSettled(cleanup.reverse().map(id => client.workflowDefinitions.delete(id))); + const failures = results.filter(result => result.status === 'rejected'); + if (failures.length) throw new AggregateError([...(primaryError ? [primaryError] : []), ...failures.map(result => result.reason)], + primaryError ? 'Settings check and temporary definition cleanup failed' : 'Temporary definition cleanup failed', { cause: primaryError }); +} diff --git a/studio/react/src/__tests__/settings-roundtrip.test.ts b/studio/react/src/__tests__/settings-roundtrip.test.ts new file mode 100644 index 0000000000..b586bbbd10 --- /dev/null +++ b/studio/react/src/__tests__/settings-roundtrip.test.ts @@ -0,0 +1,27 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +const script = fileURLToPath(new URL("../../scripts/test-settings-roundtrip.mjs", import.meta.url)); + +for (const cleanupFails of [false, true]) { + it(`retains the original settings-check failure when cleanup ${cleanupFails ? "also fails" : "succeeds"}`, () => { + // Run the real CLI against deterministic HTTP responses, with no server or credentials. + const bootstrap = `globalThis.fetch = async (url, options) => { + if (String(url).endsWith('/identity/login')) return Response.json({isAuthenticated:true,accessToken:'fixture'}); + if (options.method === 'DELETE') return ${cleanupFails ? "Response.json({title:'Cleanup fixture failure'},{status:500})" : "new Response(null,{status:204})"}; + return Response.json({workflowDefinition:{definitionId:'temporary',version:1}}); + };`; + const result = spawnSync(process.execPath, ["--import", `data:text/javascript,${encodeURIComponent(bootstrap)}`, script], { + env: { ...process.env, ELSA_TEST_API_URL: "https://fixture.invalid/api", ELSA_TEST_USERNAME: "fixture", ELSA_TEST_PASSWORD: "fixture" }, + encoding: "utf8", timeout: 10_000, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("AssertionError"); + expect(result.stderr).toContain("1 !== 2"); + if (cleanupFails) { + expect(result.stderr).toContain("Settings check and temporary definition cleanup failed"); + expect(result.stderr).toContain("Cleanup fixture failure"); + } else expect(result.stderr).not.toContain("AggregateError"); + }); +} diff --git a/studio/react/tests/activity-settings.spec.ts b/studio/react/tests/activity-settings.spec.ts new file mode 100644 index 0000000000..5924941bd9 --- /dev/null +++ b/studio/react/tests/activity-settings.spec.ts @@ -0,0 +1,187 @@ +import { expect, test } from "@playwright/test"; + +const descriptor = { typeName: "Example.Reusable", name: "Reusable", displayName: "Reusable task", version: 1, category: "Tests", kind: "Task", + inputs: [{ name: "Message", typeName: "String" }], outputs: [{ name: "Result", typeName: "String" }], ports: [], customProperties: { RootType: "WorkflowDefinitionActivity", Resilient: true } }; +const activity = { id: "child", nodeId: "root:child", type: descriptor.typeName, version: 1, workflowDefinitionId: "reusable", workflowDefinitionVersionId: "version-one", + message: { typeName: "String", expression: { type: "Literal", value: "keep" } }, customProperties: { plugin: "keep", logPersistenceMode: { inputs: { message: "Include" } } } }; + +for (const catalogFailure of [false, true]) { +test(catalogFailure ? "catalog failures preserve local clear and expression actions" : "activity settings edit current server fields, preserve adjacent data, select a version, and honor read-only", async ({ page }) => { + const fixtureActivity = catalogFailure ? { ...activity, customProperties: { ...activity.customProperties, commitStrategyName: "Always", resilienceStrategy: { mode: "Identifier", strategyId: "retry-three" } } } : activity; + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.route("**/elsa/api/**", async route => { + const path = new URL(route.request().url()).pathname; + if (catalogFailure && ["commit-strategies/activities", "log-persistence-strategies", "resilience/strategies"].some(suffix => path.endsWith(suffix))) { + await route.fulfill({ status: 503, json: { title: "Catalog unavailable" } }); + return; + } + const items = path.endsWith("features/installed") ? [{ name: "Resilience", namespace: "Elsa", fullName: "Elsa.Resilience", displayName: "Resilience" }] + : path.endsWith("commit-strategies/activities") ? [{ name: "Always", displayName: "Always commit" }] + : path.endsWith("log-persistence-strategies") ? [{ typeName: "Elsa.Workflows.LogPersistence.Strategies.Include, Elsa.Workflows.Core", displayName: "Include" }, { typeName: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core", displayName: "Exclude" }] + : path.endsWith("resilience/strategies") ? [{ id: "retry-three", displayName: "Retry three times" }] + : path.endsWith("workflow-definitions") ? [{ id: "version-two", definitionId: "reusable", version: 2, isPublished: true }, { id: "version-one", definitionId: "reusable", version: 1 }, { id: "version-unavailable", definitionId: "reusable", version: 3 }] + : []; + if (path.endsWith("workflow-definitions")) expect(new URL(route.request().url()).searchParams.get("versionOptions")).toBe("AllVersions"); + await route.fulfill({ json: { items, totalCount: items.length } }); + }); + let originalEntry = ""; + await page.route("**/studio-manifest", async route => { + const response = await route.fetch(); + const manifest = await response.json(); + const editors = manifest.modules.find((module: { id: string }) => module.id === "activity-editors"); + originalEntry = editors.entry; + editors.entry = "/settings-fixture.js"; + await route.fulfill({ json: manifest }); + }); + await page.route("**/settings-fixture.js", route => route.fulfill({ contentType: "text/javascript", body: ` + import { createElement as h, useState } from "react"; + import { useStore } from "zustand"; + import { ActivityPropertiesPanel } from "@elsa-workflows/react-studio-sdk/components"; + import { ActivityRegistry, createDesignerStore, DesignerProvider } from "@elsa-workflows/react-studio-sdk/designer/index"; + import { register as registerEditors } from ${JSON.stringify(originalEntry)}; + const child = ${JSON.stringify(fixtureActivity)}; + const registry = new ActivityRegistry([${JSON.stringify(descriptor)}, {...${JSON.stringify(descriptor)},version:2}]); + function Fixture() { + const [store] = useState(()=>{const value=createDesignerStore({id:"root",type:"Elsa.Flowchart",version:1,activities:[child],connections:[]});value.getState().select(["child"]);return value;}); + const root = useStore(store,state=>state.root); + return h(DesignerProvider,{store,registry},h("section",{style:{height:"850px",width:"700px"}}, + h("h1",null,"Activity settings fixture"), + h("button",{onClick:()=>store.setState(state=>({readOnly:!state.readOnly}))},"Toggle read only"), + h("output",{"data-testid":"saved-activity"},JSON.stringify(root.activities[0])), + h(ActivityPropertiesPanel,{variables:[],workflowInputs:[],workflowOutputs:[]}))); + } + export function register(api) {registerEditors(api);api.contribute("routes",{id:"settings-fixture",path:"/settings-fixture",component:Fixture});} + ` })); + await page.goto("/studio-preview/settings-fixture"); + await expect(page.getByRole("heading", { name: "Activity settings fixture" })).toBeVisible(); + const saved = page.getByTestId("saved-activity"); + await expect(saved).toHaveText(JSON.stringify(fixtureActivity)); + if (catalogFailure) { + await page.getByRole("tab", { name: "Commit strategy", exact: true }).click(); + await expect(page.getByRole("alert").filter({ hasText: "Could not load commit strategies" })).toBeVisible(); + await page.getByRole("combobox", { name: "Commit strategy", exact: true }).click(); + await page.getByRole("option", { name: "Inherit", exact: true }).click(); + await expect(saved).not.toContainText('"commitStrategyName"'); + await page.getByRole("tab", { name: "Log", exact: true }).click(); + await expect(page.getByRole("alert").filter({ hasText: "Could not load log persistence strategies" })).toBeVisible(); + await page.getByLabel("Input: Message mode").click(); + await page.getByRole("option", { name: "Expression", exact: true }).click(); + await page.getByLabel("Input: Message expression value").click(); + await page.getByRole("option", { name: "Include", exact: true }).click(); + await expect(saved).toContainText('"expression":{"type":"Literal","value":"Include"}'); + await page.getByLabel("Input: Message mode").click(); + await page.getByRole("option", { name: "Inherit", exact: true }).click(); + await expect(saved).toContainText('"message":null'); + await page.getByRole("tab", { name: "Resilience", exact: true }).click(); + await expect(page.getByRole("alert").filter({ hasText: "Could not load resilience strategies" })).toBeVisible(); + await page.getByLabel("Resilience mode").click(); + await page.getByRole("option", { name: "Expression", exact: true }).click(); + await page.getByLabel("Resilience expression value").fill("fallback-policy"); + await expect(saved).toContainText('"expression":{"type":"Literal","value":"fallback-policy"}'); + await page.getByLabel("Resilience mode").click(); + await page.getByRole("option", { name: "Default", exact: true }).click(); + await expect(saved).not.toContainText('"resilienceStrategy"'); + await page.getByRole("button", { name: "Toggle read only", exact: true }).click(); + await expect(page.getByLabel("Resilience mode")).toBeDisabled(); + expect(errors).toEqual([]); + return; + } + await page.getByRole("tab", { name: "Commit strategy", exact: true }).click(); + await page.getByRole("combobox", { name: "Commit strategy", exact: true }).click(); + await page.getByRole("option", { name: "Always commit", exact: true }).click(); + await expect(saved).toContainText('"commitStrategyName":"Always"'); + await page.getByRole("tab", { name: "Log", exact: true }).click(); + await expect(page.getByLabel("Input: Message strategy")).toContainText("Include"); + await page.getByLabel("Input: Message mode").click(); + await page.getByRole("option", { name: "Expression", exact: true }).click(); + await page.getByLabel("Input: Message expression value").click(); + await page.getByRole("option", { name: "Include", exact: true }).click(); + await expect(saved).toContainText('"expression":{"type":"Literal","value":"Include"}'); + await page.getByLabel("Input: Message mode").click(); + await page.getByRole("option", { name: "Inherit", exact: true }).click(); + const cleared = JSON.parse((await saved.textContent())!); + expect(cleared.customProperties.logPersistenceConfig.inputs.message).toBeNull(); + expect(cleared.customProperties.logPersistenceMode.inputs.message).toBeUndefined(); + await page.getByLabel("Output: Result mode").click(); + await page.getByRole("option", { name: "Strategy", exact: true }).click(); + await page.getByLabel("Output: Result strategy").click(); + await page.getByRole("option", { name: "Exclude", exact: true }).click(); + await expect(saved).toContainText('"strategyType":"Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core"'); + await page.getByRole("tab", { name: "Task", exact: true }).click(); + await page.getByLabel("Run asynchronously", { exact: true }).check(); + await expect(saved).toContainText('"runAsynchronously":true'); + await page.getByRole("tab", { name: "Resilience", exact: true }).click(); + await page.getByLabel("Resilience mode").click(); + await page.getByRole("option", { name: "Strategy", exact: true }).click(); + await page.getByLabel("Resilience strategy", { exact: true }).click(); + await page.getByRole("option", { name: "Retry three times", exact: true }).click(); + await expect(saved).toContainText('"resilienceStrategy":{"mode":"Identifier","strategyId":"retry-three"}'); + await page.getByRole("tab", { name: "Version", exact: true }).click(); + await expect(page.getByRole("button", { name: "Use version 3", exact: true })).toBeDisabled(); + await page.getByRole("button", { name: "Use version 2", exact: true }).click(); + await expect(saved).toContainText('"version":2'); + await expect(saved).toContainText('"workflowDefinitionVersionId":"version-two"'); + await expect(saved).toContainText('"plugin":"keep"'); + await expect(saved).toContainText('"message":{"typeName":"String","expression":{"type":"Literal","value":"keep"}}'); + await page.getByRole("button", { name: "Toggle read only", exact: true }).click(); + await expect(page.getByRole("button", { name: "Use version 1", exact: true })).toBeDisabled(); + await page.getByRole("tab", { name: "Task", exact: true }).click(); + await expect(page.getByLabel("Run asynchronously", { exact: true })).toBeDisabled(); + await page.getByRole("tab", { name: "Commit strategy", exact: true }).click(); + await expect(page.getByRole("combobox", { name: "Commit strategy", exact: true })).toBeDisabled(); + expect(errors).toEqual([]); +}); + +} + +for (const failNextPage of [false, true]) test(`instance retries select executions and recover paging (failed page: ${failNextPage})`, async ({ page }) => { + const errors: string[] = []; + const calls: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + const executions = ["first", "latest"].map((name, index) => ({ id: `execution-${name}`, workflowInstanceId: "instance", activityId: "child", activityNodeId: "root:child", activityType: descriptor.typeName, + activityTypeVersion: 1, activityName: "Retry task", status: "Completed", startedAt: `2026-10-20T10:00:0${index}.000Z`, completedAt: `2026-10-20T10:00:0${index + 1}.000Z` })); + await page.route("**/elsa/api/**", async route => { + const url = new URL(route.request().url()); + const path = url.pathname; + let json: unknown = { items: [], totalCount: 0 }; + if (path.endsWith("descriptors/activities")) json = { items: [descriptor] }; + else if (path.endsWith("workflow-instances/instance")) json = { id: "instance", definitionId: "parent", definitionVersionId: "parent-version", version: 1, status: "Finished", subStatus: "Finished", workflowState: { incidents: [], bookmarks: [] } }; + else if (path.endsWith("workflow-definitions/by-id/parent-version")) json = { id: "parent-version", definitionId: "parent", version: 1, name: "Retry example", root: { id: "root", type: "Elsa.Flowchart", version: 1, activities: [activity], connections: [] } }; + else if (path.endsWith("activity-executions/list")) json = { items: executions }; + else if (path.endsWith("activity-executions/report")) json = { stats: [] }; + else if (path.includes("resilience/retries/")) { + calls.push(`${path}?${url.searchParams}`); + const latest = path.endsWith("execution-latest"); + const skip = Number(url.searchParams.get("skip")); + if (failNextPage && skip > 0) { await route.fulfill({ status: 503, json: { message: "Retry page unavailable" } }); return; } + json = { items: latest ? Array.from({ length: skip === 0 ? 10 : 1 }, (_, index) => ({ id: `attempt-${skip + index}`, activityInstanceId: "execution-latest", attemptNumber: skip + index, + retryDelay: "00:00:01", details: { exception: "Temporary failure", custom: "Preserved details" } })) : [], totalCount: latest ? 11 : 0 }; + } + await route.fulfill({ json }); + }); + await page.goto("/studio-preview/workflows/instances/instance/view"); + await page.locator('.react-flow__node[data-id="child"]').click(); + await page.getByRole("tab", { name: "Retries", exact: true }).click(); + await expect(page.getByText("11 attempts", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Details for attempt 0", exact: true }).click(); + await expect(page.getByText("Preserved details", { exact: false })).toBeVisible(); + await page.getByRole("button", { name: "Next attempts", exact: true }).click(); + if (failNextPage) { + await expect(page.getByText("Could not load retry attempts", { exact: true })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("button", { name: "Next attempts", exact: true })).toBeDisabled(); + await page.getByRole("button", { name: "Previous attempts", exact: true }).click(); + await expect(page.getByRole("button", { name: "Details for attempt 0", exact: true })).toBeVisible(); + } else { + await expect(page.getByRole("button", { name: "Details for attempt 10", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Next attempts", exact: true })).toBeDisabled(); + } + await page.getByLabel("Retry activity execution").click(); + await page.getByRole("option", { name: /execution-first/ }).click(); + await expect(page.getByText("No retry attempts for this execution.", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Details for attempt 10", exact: true })).toHaveCount(0); + expect(calls.some(call => call.includes("execution-latest?skip=10&take=10"))).toBe(true); + expect(calls.some(call => call.includes("execution-first?skip=0&take=10"))).toBe(true); + expect(calls.every(call => !call.includes("retries/child"))).toBe(true); + expect(errors).toEqual([]); +});