mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
feat: detach the flow graph step panel below a width breakpoint
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016eu85JrwsztjfXGNQAATRz
This commit is contained in:
co-authored by
Claude Opus 5
parent
21c3fb9aea
commit
e2defaceed
@@ -7,6 +7,13 @@
|
||||
import { writable } from 'svelte/store'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { MousePointerClick } from 'lucide-svelte'
|
||||
import Modal from './common/modal/Modal.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import FlowPanelPlacementPicker from './flows/common/FlowPanelPlacementPicker.svelte'
|
||||
import { useFlowPanelMode } from './flows/flowPanelMode.svelte'
|
||||
import type { FlowPanelDetachContext } from './flows/types'
|
||||
import { stepLabel } from './flows/stepLabel'
|
||||
|
||||
import FlowGraphViewerStep from './FlowGraphViewerStep.svelte'
|
||||
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
|
||||
@@ -55,12 +62,95 @@
|
||||
}: Props = $props()
|
||||
|
||||
let availableHeight = $state(0)
|
||||
// The step panel stays hidden below Tailwind's sm breakpoint, as it did when this was a
|
||||
// `hidden sm:flex` grid cell: a Pane keeps its width even when its content is display:none,
|
||||
// so the breakpoint has to decide whether the Pane exists at all.
|
||||
let innerWidth = $state(0)
|
||||
let showSide = $derived(
|
||||
!noSide && !(hideDefaultInputs && stepDetail == undefined) && innerWidth >= 640
|
||||
let availableWidth = $state(0)
|
||||
|
||||
// Same placement rule as the flow editor, off the same breakpoint: 'docked' puts the step
|
||||
// panel in a pane beside the graph, 'modal' gives the graph the full width and moves the
|
||||
// panel into a dialog opened by double-clicking a step. Measured on this component rather
|
||||
// than the window, because the viewer is often embedded in a pane far narrower than it.
|
||||
const panelController = useFlowPanelMode({ enabled: () => !noSide && !noGraph })
|
||||
$effect(() => panelController.measure(availableWidth))
|
||||
let panelMode = $derived(panelController.mode)
|
||||
let stepModalOpen = $state(false)
|
||||
|
||||
// Supplying this context is what puts the Auto/Attached/Detached picker in the graph's own
|
||||
// control bar — FlowGraphV2 renders it already and hides it wherever the context is absent.
|
||||
setContext<FlowPanelDetachContext>('flowPanelDetach', {
|
||||
// The viewer's panel draws no card header, so nothing claims the chrome.
|
||||
claim: () => () => {},
|
||||
modalOpen: () => panelMode === 'modal' && stepModalOpen,
|
||||
close: () => (stepModalOpen = false),
|
||||
enabled: () => !noSide && !noGraph,
|
||||
preference: () => panelController.preference,
|
||||
setPreference: (preference) => {
|
||||
if (preference === panelController.preference) return
|
||||
// Moving the panel must not lose what it was showing: docked, it is always on screen,
|
||||
// so the dialog it becomes has to open on arrival. The reverse is handled by the
|
||||
// effect below, which closes a dialog that is no longer rendered.
|
||||
const wasVisible = panelMode === 'docked' || stepModalOpen
|
||||
panelController.preference = preference
|
||||
stepModalOpen = panelController.mode === 'modal' && wasVisible
|
||||
}
|
||||
})
|
||||
// Whether the panel has anything to show. Kept apart from panelMode so the double-click
|
||||
// gesture stays live under hideDefaultInputs, where nothing shows until a step is picked.
|
||||
let hasSideContent = $derived(!noSide && !(hideDefaultInputs && stepDetail == undefined))
|
||||
|
||||
// A move back to 'docked' — the viewer got wider — would otherwise leave a dialog open
|
||||
// over a panel that is already visible beside the graph.
|
||||
$effect(() => {
|
||||
if (panelMode === 'docked' && untrack(() => stepModalOpen)) {
|
||||
stepModalOpen = false
|
||||
}
|
||||
})
|
||||
|
||||
// Asset and note nodes are deliberately unselectable, and the In/Out bar inside a node is
|
||||
// a picker that opens and shuts on click — neither is a request to see a step's details.
|
||||
function selectableNodeAt(e: MouseEvent): HTMLElement | null {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target?.closest('[data-prop-picker]')) return null
|
||||
return target?.closest('.svelte-flow__node.selectable') ?? null
|
||||
}
|
||||
|
||||
function openStepModalFromGraph(e: MouseEvent) {
|
||||
if (selectableNodeAt(e)) stepModalOpen = true
|
||||
}
|
||||
|
||||
// Clicking the step that is already selected is the second half of "select it, then show
|
||||
// it". Read in the capture phase: once the click bubbles, the graph has applied its own
|
||||
// selection and a first click looks identical to this one.
|
||||
let clickStartedOnSelected = false
|
||||
function noteSelectionBeforeClick(e: MouseEvent) {
|
||||
clickStartedOnSelected = Boolean(selectableNodeAt(e)?.classList.contains('selected'))
|
||||
}
|
||||
|
||||
function openStepModalIfReselected(e: MouseEvent) {
|
||||
if (clickStartedOnSelected && selectableNodeAt(e)) stepModalOpen = true
|
||||
}
|
||||
|
||||
let stepModalStep = $derived(
|
||||
typeof stepDetail === 'object' && stepDetail != undefined ? stepDetail : undefined
|
||||
)
|
||||
// Flow-level targets ('Input', 'Result', …) reach the panel as a bare string and have no id
|
||||
// or label of their own — the string is the name. With nothing selected the panel shows the
|
||||
// flow's inputs, which is what detaching from an empty selection opens on.
|
||||
let stepModalTitle = $derived(
|
||||
stepModalStep
|
||||
? stepLabel(stepModalStep)
|
||||
: typeof stepDetail === 'string'
|
||||
? stepDetail
|
||||
: 'Flow inputs'
|
||||
)
|
||||
let stepModalBadge = $derived(
|
||||
stepModalStep?.id && stepModalStep.id != 'failure' && stepModalStep.id != 'preprocessor'
|
||||
? stepModalStep.id
|
||||
: undefined
|
||||
)
|
||||
|
||||
let stepHintText = $derived(
|
||||
typeof stepDetail === 'object' && stepDetail != undefined
|
||||
? 'Click the selected step to see its details'
|
||||
: 'Double click a step to see its details'
|
||||
)
|
||||
|
||||
if (provideTriggerContext && !hasContext('TriggerContext')) {
|
||||
@@ -95,10 +185,12 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
<div bind:clientHeight={availableHeight} class="w-full h-full min-h-0">
|
||||
{#if !noGraph && showSide}
|
||||
<div
|
||||
bind:clientHeight={availableHeight}
|
||||
bind:clientWidth={availableWidth}
|
||||
class="w-full h-full min-h-0 relative"
|
||||
>
|
||||
{#if !noGraph && hasSideContent && panelMode === 'docked'}
|
||||
<Splitpanes class="w-full h-full">
|
||||
<Pane size={66} minSize={25}>
|
||||
{@render graph()}
|
||||
@@ -109,16 +201,54 @@
|
||||
</Splitpanes>
|
||||
{:else if !noGraph}
|
||||
{@render graph()}
|
||||
{:else if showSide}
|
||||
{:else if hasSideContent}
|
||||
{@render side()}
|
||||
{/if}
|
||||
|
||||
{#if panelMode === 'modal' && !stepModalOpen}
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-2 left-3 z-30 flex items-center gap-1.5 text-xs text-hint"
|
||||
>
|
||||
<MousePointerClick size={13} />
|
||||
{stepHintText}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={stepModalOpen} title={stepModalTitle} kind="X" titleBadgeFirst class="max-w-4xl">
|
||||
{#snippet titleBadge()}
|
||||
{#if stepModalBadge}
|
||||
<Badge color="indigo" small class="shrink-0 !py-0 leading-4">{stepModalBadge}</Badge>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<!-- The picker in the graph's control bar is behind this dialog, so re-attaching needs its
|
||||
own way back from in here. -->
|
||||
{#snippet settings()}
|
||||
<FlowPanelPlacementPicker variant="header" />
|
||||
{/snippet}
|
||||
<!-- The dialog supplies the padding and names the step, and hugs a short step while
|
||||
scrolling a long one: without fillHeight it sizes to its content, which would
|
||||
otherwise run past the viewport on a step with a long script. -->
|
||||
<FlowGraphViewerStep
|
||||
schema={flow?.schema}
|
||||
{stepDetail}
|
||||
{hideDefaultInputs}
|
||||
{workspace}
|
||||
hideHeader
|
||||
class="p-0 max-h-[70vh] overflow-y-auto"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{#snippet graph()}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="w-full h-full min-h-0 max-h-full"
|
||||
class:overflow-auto={overflowAuto}
|
||||
class:border={!noBorder}
|
||||
ondblclick={panelMode === 'modal' ? openStepModalFromGraph : undefined}
|
||||
onpointerdowncapture={panelMode === 'modal' ? noteSelectionBeforeClick : undefined}
|
||||
onclick={panelMode === 'modal' ? openStepModalIfReselected : undefined}
|
||||
>
|
||||
<FlowGraphV2
|
||||
{triggerNode}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import FlowModuleScript from './flows/content/FlowModuleScript.svelte'
|
||||
import { stepLabel } from './flows/stepLabel'
|
||||
import { Copy, Expand } from 'lucide-svelte'
|
||||
import HighlightTheme from './HighlightTheme.svelte'
|
||||
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
|
||||
@@ -28,6 +29,10 @@
|
||||
// The workspace the viewed flow belongs to (differs from the nav workspace in fork/session
|
||||
// editors); used to qualify resource links.
|
||||
workspace?: string
|
||||
/** Overrides the root's padding and scrolling, for hosts that pad and scroll themselves. */
|
||||
class?: string
|
||||
/** Drops the id + label strip, for a host that already names the step in its own chrome. */
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,7 +40,9 @@
|
||||
stepDetail = undefined,
|
||||
jobScriptHash = undefined,
|
||||
hideDefaultInputs = false,
|
||||
workspace = undefined
|
||||
workspace = undefined,
|
||||
class: className = '',
|
||||
hideHeader = false
|
||||
}: Props = $props()
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
let codeViewer: Drawer | undefined = $state()
|
||||
@@ -94,12 +101,16 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div class={twMerge('p-2 overflow-y-scroll')}>
|
||||
<div class={twMerge('p-2 overflow-y-scroll', className)}>
|
||||
{#if stepDetail == undefined}
|
||||
<div>
|
||||
<p class="text-secondary text-xs italic px-2 pt-2"> Click on a step to see its details </p>
|
||||
{#if !hideHeader}
|
||||
<p class="text-secondary text-xs italic px-2 pt-2"> Click on a step to see its details </p>
|
||||
{/if}
|
||||
{#if schema && !hideDefaultInputs}
|
||||
<h3 class="mb-2 font-semibold">Flow Inputs</h3>
|
||||
{#if !hideHeader}
|
||||
<h3 class="mb-2 font-semibold">Flow Inputs</h3>
|
||||
{/if}
|
||||
<SchemaViewer {schema} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -113,48 +124,23 @@
|
||||
<p class="font-medium text-secondary text-center pt-4 pb-8"> End of the flow </p>
|
||||
{:else if typeof stepDetail != 'string' && stepDetail.value}
|
||||
<div class="">
|
||||
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
|
||||
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
|
||||
<Badge color="indigo">
|
||||
{stepDetail.id}
|
||||
</Badge>
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge(
|
||||
'font-semibold text-emphasis text-sm',
|
||||
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
|
||||
)}
|
||||
>
|
||||
{#if stepDetail.summary}
|
||||
{stepDetail.summary}
|
||||
{:else if stepDetail.value.type == 'identity'}
|
||||
Identity
|
||||
{:else if stepDetail.value.type == 'forloopflow'}
|
||||
For loop {#if stepDetail.value.parallel}(parallel){/if}
|
||||
{#if stepDetail.value.skip_failures}(skip failures){/if}
|
||||
{#if stepDetail.value.squash}(squash){/if}
|
||||
{:else if stepDetail.value.type == 'branchall'}
|
||||
Run all branches {#if stepDetail.value.parallel}(parallel){/if}
|
||||
{:else if stepDetail.value.type == 'branchone'}
|
||||
Run one branch
|
||||
{:else if stepDetail.value.type == 'flow'}
|
||||
Inner flow
|
||||
{:else if stepDetail.value.type == 'whileloopflow'}
|
||||
While loop {#if stepDetail.value.skip_failures}(skip failures){/if}
|
||||
{#if stepDetail.value.squash}(squash){/if}
|
||||
{:else if stepDetail.id === 'failure'}
|
||||
Error handler
|
||||
{:else if stepDetail.id === 'preprocessor'}
|
||||
Preprocessor
|
||||
{:else if stepDetail.value.type == 'rawscript'}
|
||||
Inline {stepDetail.value.language} script
|
||||
{:else if stepDetail.value.type == 'script'}
|
||||
Workspace script
|
||||
{:else if stepDetail.value.type == 'aiagent'}
|
||||
AI Agent
|
||||
{#if !hideHeader}
|
||||
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
|
||||
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
|
||||
<Badge color="indigo">
|
||||
{stepDetail.id}
|
||||
</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class={twMerge(
|
||||
'font-semibold text-emphasis text-sm',
|
||||
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
|
||||
)}
|
||||
>
|
||||
{stepLabel(stepDetail)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stepDetail.value.type == 'script'}
|
||||
<div class="pb-2">
|
||||
<a
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
/** Rendered against the dialog's own name, before any level below it: what it marks is the
|
||||
* dialog rather than wherever in it you have navigated to. */
|
||||
titleBadge?: import('svelte').Snippet
|
||||
/** Puts the badge ahead of the title, for a badge that identifies the subject rather than
|
||||
* qualifying it — an id reads before the name it belongs to, a "Beta" tag reads after. */
|
||||
titleBadgeFirst?: boolean
|
||||
settings?: import('svelte').Snippet
|
||||
children?: import('svelte').Snippet
|
||||
actions?: import('svelte').Snippet
|
||||
@@ -67,6 +70,7 @@
|
||||
fillHeight = false,
|
||||
minZIndex: minZIndexProp = undefined,
|
||||
titleBadge,
|
||||
titleBadgeFirst = false,
|
||||
settings,
|
||||
children: children_render,
|
||||
actions
|
||||
@@ -259,14 +263,26 @@
|
||||
{@render settings?.()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<!-- pr-8 under `kind="X"`: the close button is absolutely positioned, so a long
|
||||
title or anything in `settings` would otherwise run under it. -->
|
||||
<div
|
||||
class="flex flex-row items-center justify-between gap-2 min-w-0 {kind ===
|
||||
'X'
|
||||
? 'pr-8'
|
||||
: ''}"
|
||||
>
|
||||
<h3
|
||||
class="text-emphasis text-lg font-semibold {titleBadge
|
||||
? 'flex items-center gap-1'
|
||||
? 'flex items-center gap-1.5 min-w-0'
|
||||
: ''}"
|
||||
>
|
||||
{title}
|
||||
{@render titleBadge?.()}
|
||||
{#if titleBadgeFirst}
|
||||
{@render titleBadge?.()}
|
||||
<span class="truncate">{title}</span>
|
||||
{:else}
|
||||
{title}
|
||||
{@render titleBadge?.()}
|
||||
{/if}
|
||||
</h3>
|
||||
{@render settings?.()}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
|
||||
/**
|
||||
* What to call a step that has no summary. The id checks sit between the composite types and
|
||||
* the leaf script types on purpose: a failure module holds a rawscript, and reading it as
|
||||
* "Inline python3 script" loses the only thing that distinguishes it.
|
||||
*/
|
||||
export function stepLabel(step: FlowModule): string {
|
||||
if (step.summary) return step.summary
|
||||
|
||||
const value = step.value as Record<string, any> | undefined
|
||||
const suffixes = (...flags: [boolean | undefined, string][]) =>
|
||||
flags
|
||||
.filter(([on]) => on)
|
||||
.map(([, label]) => ` (${label})`)
|
||||
.join('')
|
||||
|
||||
switch (value?.type) {
|
||||
case 'identity':
|
||||
return 'Identity'
|
||||
case 'forloopflow':
|
||||
return (
|
||||
'For loop' +
|
||||
suffixes(
|
||||
[value.parallel, 'parallel'],
|
||||
[value.skip_failures, 'skip failures'],
|
||||
[value.squash, 'squash']
|
||||
)
|
||||
)
|
||||
case 'branchall':
|
||||
return 'Run all branches' + suffixes([value.parallel, 'parallel'])
|
||||
case 'branchone':
|
||||
return 'Run one branch'
|
||||
case 'flow':
|
||||
return 'Inner flow'
|
||||
case 'whileloopflow':
|
||||
return (
|
||||
'While loop' + suffixes([value.skip_failures, 'skip failures'], [value.squash, 'squash'])
|
||||
)
|
||||
}
|
||||
|
||||
if (step.id === 'failure') return 'Error handler'
|
||||
if (step.id === 'preprocessor') return 'Preprocessor'
|
||||
|
||||
switch (value?.type) {
|
||||
case 'rawscript':
|
||||
return `Inline ${value.language} script`
|
||||
case 'script':
|
||||
return 'Workspace script'
|
||||
case 'aiagent':
|
||||
return 'AI Agent'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -4,11 +4,11 @@
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import FlowPathViewer from '$lib/components/flows/content/FlowPathViewer.svelte'
|
||||
import { FlowService } from '$lib/gen'
|
||||
import { FlowService, type OpenFlow } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { untrack } from 'svelte'
|
||||
import { fixtureFlow } from './fixtureFlow'
|
||||
import { fixtureFlow, subFixtureFlow } from './fixtureFlow'
|
||||
|
||||
// FlowPathViewer takes a path and fetches, so the fixture has to exist in the workspace.
|
||||
// Seeding on load keeps fixtureFlow.ts the single source of truth for the graph.
|
||||
@@ -30,23 +30,23 @@
|
||||
short: 'height: 260px; width: 100%'
|
||||
}
|
||||
|
||||
async function upsert(workspace: string, p: string, flow: OpenFlow) {
|
||||
const body = { path: p, ...flow, deployment_message: 'dev fixture' }
|
||||
if (await FlowService.existsFlowByPath({ workspace, path: p })) {
|
||||
await FlowService.updateFlow({ workspace, path: p, requestBody: body })
|
||||
} else {
|
||||
await FlowService.createFlow({ workspace, requestBody: body })
|
||||
}
|
||||
}
|
||||
|
||||
async function seed(workspace: string, p: string) {
|
||||
seeding = true
|
||||
error = undefined
|
||||
try {
|
||||
const exists = await FlowService.existsFlowByPath({ workspace, path: p })
|
||||
if (exists) {
|
||||
await FlowService.updateFlow({
|
||||
workspace,
|
||||
path: p,
|
||||
requestBody: { path: p, ...fixtureFlow, deployment_message: 'dev fixture' }
|
||||
})
|
||||
} else {
|
||||
await FlowService.createFlow({
|
||||
workspace,
|
||||
requestBody: { path: p, ...fixtureFlow, deployment_message: 'dev fixture' }
|
||||
})
|
||||
}
|
||||
// Subflow first: the main fixture's step 'm' points at it, and a step whose target
|
||||
// does not exist renders as not-found instead of a nested graph.
|
||||
await upsert(workspace, `${p}_sub`, subFixtureFlow)
|
||||
await upsert(workspace, p, fixtureFlow(`${p}_sub`))
|
||||
seeded = undefined
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
seeded = p
|
||||
|
||||
@@ -17,13 +17,24 @@ function step(
|
||||
|
||||
const js = (expr: string) => ({ type: 'javascript' as const, expr })
|
||||
|
||||
/** Target of the fixture's subflow step, so the step panel has a nested graph to draw. */
|
||||
export const subFixtureFlow: OpenFlow = {
|
||||
summary: 'Refund a line item (dev fixture subflow)',
|
||||
value: {
|
||||
modules: [
|
||||
step('a', 'Void the charge', 'bun', 'export async function main() {}\n'),
|
||||
step('b', 'Restock the item', 'python3', 'def main():\n return "restocked"\n')
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the graph this fixture draws, so a change here can be judged against intent:
|
||||
* a straight step, a for-loop with two nested steps, a three-way branchone, a branchall
|
||||
* with a skip_failure branch, a step carrying retry/cache/early-stop badges, plus a
|
||||
* failure module, a preprocessor, a note and a group.
|
||||
* with a skip_failure branch, a subflow, a step carrying retry/cache/early-stop badges,
|
||||
* plus a failure module, a preprocessor, a note and a group.
|
||||
*/
|
||||
export const fixtureFlow: OpenFlow = {
|
||||
export const fixtureFlow = (subflowPath: string): OpenFlow => ({
|
||||
summary: 'Order fulfilment (dev fixture)',
|
||||
description:
|
||||
'Fake flow rendered by /dev/flow_path_viewer. Edit fixtureFlow.ts and hit Re-seed to change the graph.',
|
||||
@@ -101,6 +112,11 @@ export const fixtureFlow: OpenFlow = {
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'm',
|
||||
summary: 'Refund rejected items',
|
||||
value: { type: 'flow', input_transforms: {}, path: subflowPath }
|
||||
},
|
||||
step('l', 'Close the order', 'python3', 'def main():\n return "done"\n', {
|
||||
retry: { constant: { attempts: 3, seconds: 5 } },
|
||||
cache_ttl: 3600,
|
||||
@@ -138,4 +154,4 @@ export const fixtureFlow: OpenFlow = {
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user