Files
windmill/frontend/src/lib/components/common/table/FlowRow.svelte
T
AlexRV12andClaude Opus 5 0df128cc93 feat: let operators compose flows when the workspace grants the right
Adds operator_settings.builder_flows: a workspace setting that lets every
operator compose flows out of runnables that already exist. It does not make
them authors. The boundary the operator role draws is authoring code and running
arbitrary code, and this does not move it: check_flow_is_composition_only walks
the value and refuses anything carrying code, including the shapes an obvious
walk misses (code hoisted into a flow_node, an AI agent step's tools, and a
linked ai_agent resource whose tool list is resolved at run time).

What the walk cannot settle it returns for the caller to authorize under RLS:
the worker tags the steps pin, every runnable they reference, and the (path,
hash) of every version-pinned step. Composing a path is enough to run it and to
run it as whoever it runs as, since the worker resolves a step's path with the
root DB handle and adopts that runnable's on_behalf_of. A pinned hash needs its
own check because dispatch ignores the path beside it.

The gate runs on every write and on both request-supplied-value paths, flow
preview and flow dependencies, or either becomes the way to run what the write
path refuses.

Operators of a builder workspace consume a full author seat; the EE companion
carries the counting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4
2026-09-18 17:53:11 +02:00

363 lines
10 KiB
Svelte

<script lang="ts">
import { goto } from '$lib/navigation'
import { base } from '$lib/base'
import Dropdown from '$lib/components/DropdownV2.svelte'
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { operatorBuilderFlows, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import type { RowSelection } from './rowSelection'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import {
Pen,
GitFork,
Trash,
List,
FolderOpen,
ChevronUpSquare,
Calendar,
Shield,
Archive,
Copy,
Eye,
HistoryIcon
} from 'lucide-svelte'
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
import EditInForkButton from './EditInForkButton.svelte'
import { isCloudHosted } from '$lib/cloud'
interface Props {
flow: Flow & {
draft_only?: boolean
is_draft?: boolean
draft_path?: string
draft_users?: { username?: string | null }[]
canWrite: boolean
/** Projected from the flow value by the listing; a chat-input flow opens
* as a conversation and is badged as such. */
chat_input_enabled?: boolean
}
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
deleteConfirmedCallback: (() => void) | undefined
deploymentDrawer: DeployWorkspaceDrawer
errorHandlerMuted: boolean
depth?: number
menuOpen?: boolean
showEditButton?: boolean
keyboardSelected?: boolean
rowSelection?: RowSelection
}
let {
flow,
marked,
shareModal,
moveDrawer,
deleteConfirmedCallback = $bindable(),
deploymentDrawer,
errorHandlerMuted,
depth = 0,
menuOpen = $bindable(false),
showEditButton = $bindable(true),
keyboardSelected = false,
rowSelection = undefined
}: Props = $props()
const dispatch = createEventDispatcher()
async function archiveFlow(path: string, archived: boolean): Promise<void> {
try {
await FlowService.archiveFlowByPath({
workspace: $workspaceStore!,
path,
requestBody: { archived }
})
dispatch('change')
sendUserToast(`Archived flow ${path}`)
} catch (err) {
sendUserToast(`Could not archive this flow ${err.body}`, true)
}
}
async function deleteFlow(path: string): Promise<void> {
try {
// Draft-only items have no deployed row to delete — the regular
// route would 404. Route the delete through the syncer so the
// per-user draft row is removed instead.
if (flow.draft_only) {
await UserDraftDbSyncer.save({
workspace: $workspaceStore!,
itemKind: 'flow',
path,
value: null,
immediate: true
})
} else {
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
}
dispatch('change')
sendUserToast(`Deleted flow ${path}`)
} catch (err) {
sendUserToast(`Could not delete this flow ${err.body}`, true)
}
}
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
let flowHistory: FlowHistory | undefined = $state(undefined)
</script>
{#if menuOpen}
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
<FlowHistory bind:this={flowHistory} path={flow.path} />
{/if}
{#snippet chatBadge()}
<ChatFlowBadge />
{/snippet}
<Row
aiId={`flow-row-${flow.path}`}
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
href={flow.draft_only
? `${base}/flows/edit/${flow.path}`
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
kind="flow"
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
{keyboardSelected}
{marked}
path={flow.draft_path ?? flow.path}
summary={flow.is_draft ? `${flow.summary || flow.draft_path || flow.path}*` : flow.summary}
{errorHandlerMuted}
canFavorite={!flow.draft_only}
{depth}
{rowSelection}
titleBadge={flow.chat_input_enabled ? chatBadge : undefined}
>
{#snippet badges()}
{#if flow.archived}
<Badge color="red" baseClass="border">archived</Badge>
{/if}
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
<DraftBadge
is_draft={flow.is_draft}
draft_only={flow.draft_only}
draft_users={flow.draft_users}
currentUsername={$userStore?.username}
workspace={$workspaceStore ?? undefined}
itemKind="flow"
path={flow.path}
onMigrated={() => dispatch('change')}
/>
{#if flow.labels?.length}
<div class="flex items-center gap-0.5">
{#each flow.labels.slice(0, 3) as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{#if flow.labels.length > 3}
<Badge
color="blue"
small
class="px-1"
title={flow.labels
.slice(3)
.map((l) => 'Label: ' + l)
.join('\n')}>+{flow.labels.length - 3}</Badge
>
{/if}
</div>
{/if}
<InheritedLabels labels={flow.inherited_labels} />
<div class="w-8 center-center"></div>
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
{#if !$userStore?.operator}
{#if showEditButton && flow.canWrite && !flow.archived}
<div>
<Button
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
startIcon={{ icon: Pen }}
href="{base}/flows/edit/{flow.path}"
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
>
Edit
</Button>
</div>
{/if}
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
<EditInForkButton itemType="flow" path={flow.path} />
{/if}
{/if}
</span>
<Dropdown
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
items={async () => {
let { draft_only, path, archived } = flow
let owner = isOwner(path, $userStore, $workspaceStore)
const canEdit = flow.canWrite && showEditButton
if (draft_only) {
return [
{
displayName: 'Delete',
icon: Trash,
action: (event) => {
// @ts-ignore
if (event?.shiftKey) {
deleteFlow(path)
} else {
deleteConfirmedCallback = () => {
deleteFlow(path)
}
}
},
type: 'delete',
// A draft-only row is always the authed user's own draft (the
// list endpoint only surfaces own/legacy draft-only rows), so
// discarding it never requires write permission on the path.
disabled: !showEditButton,
hide: $userStore?.operator && !$operatorBuilderFlows
}
]
}
return [
{
displayName: 'View runs',
icon: List,
href: `${base}/runs/${path}`
},
{
displayName: 'Duplicate/Fork',
icon: GitFork,
href: `${base}/flows/add?template=${path}`,
disabled: !showEditButton,
hide: $userStore?.operator && !$operatorBuilderFlows
},
{
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
icon: Pen,
// No `href`: the handler resolves the destination asynchronously, and a melt
// menu item's anchor navigates before a delegated onclick can preventDefault it.
action: (e) => onEditInForkClick(e, 'flow', path),
hide:
$userStore?.operator ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
},
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`,
hide: $userStore?.operator
},
{
displayName: 'Move/Rename',
icon: FolderOpen,
action: () => {
moveDrawer.openDrawer(path, flow.summary, 'flow')
},
disabled: !owner || archived || !canEdit,
hide: $userStore?.operator && !$operatorBuilderFlows
},
{
displayName: 'Copy path',
icon: Copy,
action: () => {
copyToClipboard(path)
}
},
...(isDeployable('flow', path, await getDeployUiSettings())
? [
{
displayName: 'Deploy to staging/prod',
icon: ChevronUpSquare,
action: () => {
deploymentDrawer.openDrawer(path, 'flow')
},
disabled: archived,
hide: $userStore?.operator
}
]
: []),
{
displayName: 'Deployments',
icon: HistoryIcon,
action: () => {
flowHistory?.open()
},
hide: $userStore?.operator && !$operatorBuilderFlows
},
{
displayName: 'Schedule',
icon: Calendar,
action: () => {
scheduleEditor?.openNew(true, path)
},
disabled: archived,
hide: $userStore?.operator && !$operatorBuilderFlows
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
},
hide: $userStore?.operator && !$operatorBuilderFlows
},
{
displayName: archived ? 'Unarchive' : 'Archive',
icon: Archive,
action: () => {
path && archiveFlow(path, !archived)
},
type: 'delete',
disabled: !owner || !canEdit,
hide: $userStore?.operator && !$operatorBuilderFlows
},
{
displayName: 'Delete',
icon: Trash,
action: (event) => {
// @ts-ignore
if (event?.shiftKey) {
deleteFlow(path)
} else {
deleteConfirmedCallback = () => {
deleteFlow(path)
}
}
},
type: 'delete',
disabled: !owner || !canEdit,
hide: $userStore?.operator && !$operatorBuilderFlows
}
]
}}
on:open={() => {
menuOpen = true
}}
/>
{/snippet}
</Row>