mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 16:03:27 +00:00
* feat(frontend): run a linked agent's draft when testing a flow, and offer to deploy it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): settle an agent's autosave before reading it, and refresh its card on a draft save Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): deploy the agent draft that was validated, and make the draft-tools flag explicit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): refuse a stale agent deploy, and warn when a never-deployed agent is kept as a draft Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * docs: record what inlining an agent draft puts in a preview job Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): name the draft-changes dialog after what it lists Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): refuse a draft deploy when the draft row is gone Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): apply the missing-draft refusal to raw apps too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): stop reading a deployed resource row as a draft on deploy Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): do not mistake an outage or a vanished draft for a deploy Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): refuse an agent read whose pending draft save failed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): space the trigger badges and right-align the agent actions Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): surface a failed agent-draft read instead of dropping it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): give the agent draft delete a baseline so a newer edit survives Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): drop the agent draft cell locally instead of deleting twice Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * test(frontend): pass the withDraft flag the guard tests were missing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * refactor(frontend): deploy agent drafts the way Review & Deploy does Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): give the read-only flow graph its own linked-tools bucket Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): base the resource draft delete on the read that promoted it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): refresh every step linking an agent when its draft is saved Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * fix(frontend): write nothing at all when a resource draft has gone Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp * test(frontend): pin that the resource draft delete follows its baseline seed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
324 lines
12 KiB
Svelte
324 lines
12 KiB
Svelte
<script lang="ts">
|
|
import ConfirmationModal from './ConfirmationModal.svelte'
|
|
import { createEventDispatcher, untrack } from 'svelte'
|
|
import type { Trigger } from '$lib/components/triggers/utils'
|
|
import DataTable from '$lib/components/table/DataTable.svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
|
|
import { triggerIconMap } from '$lib/components/triggers/utils'
|
|
import { Bot, Star } from 'lucide-svelte'
|
|
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
|
|
import { userStore } from '$lib/stores'
|
|
import Badge from '../badge/Badge.svelte'
|
|
import type { LinkedAgentDraft } from '$lib/components/flows/linkedAgentDrafts'
|
|
|
|
interface Props {
|
|
open?: boolean
|
|
draftTriggers?: Trigger[]
|
|
/** Saved agents this flow links to that have an unsaved draft. Scripts pass none: only a
|
|
* flow step can link an agent. */
|
|
draftAgents?: LinkedAgentDraft[]
|
|
/** Whether this user may write each listed agent's resource, keyed by path. */
|
|
agentCanWrite?: Record<string, boolean>
|
|
/** Why an agent cannot be deployed, keyed by path, from the same rule the agent editor's own
|
|
* Deploy button follows — so this dialog cannot offer a write that would be rejected. Decided
|
|
* by the caller: this is a generic dialog the script editor mounts too, and agent validation
|
|
* has no business in its bundle. */
|
|
agentRefusal?: Record<string, string | undefined>
|
|
isFlow?: boolean
|
|
}
|
|
|
|
let {
|
|
open = $bindable(false),
|
|
draftTriggers = [],
|
|
draftAgents = [],
|
|
agentCanWrite = {},
|
|
agentRefusal = {},
|
|
isFlow = false
|
|
}: Props = $props()
|
|
|
|
let selectedTriggers: Trigger[] = $state(untrack(() => draftTriggers))
|
|
let selectedAgents: LinkedAgentDraft[] = $state([])
|
|
|
|
const dispatch = createEventDispatcher<{
|
|
canceled: void
|
|
confirmed: { selectedTriggers: Trigger[]; selectedAgents: LinkedAgentDraft[] }
|
|
}>()
|
|
|
|
function toggleTrigger(trigger: Trigger, selected: 'discard' | 'deploy') {
|
|
if (selected === 'discard') {
|
|
if (trigger.isDraft) {
|
|
selectedTriggers = selectedTriggers.filter((t) => !t.isDraft || t.id !== trigger.id)
|
|
} else {
|
|
selectedTriggers = selectedTriggers.filter(
|
|
(t) => t.isDraft || t.type !== trigger.type || t.path !== trigger.path
|
|
)
|
|
}
|
|
} else if (!isSelected(selectedTriggers, trigger)) {
|
|
selectedTriggers = [...selectedTriggers, trigger]
|
|
}
|
|
}
|
|
|
|
function isSelected(triggers: Trigger[], trigger: Trigger): boolean {
|
|
if (trigger.isDraft) {
|
|
return triggers.some((t) => t.id === trigger.id)
|
|
} else {
|
|
return triggers.some((t) => t.path === trigger.path && t.type === trigger.type)
|
|
}
|
|
}
|
|
|
|
function toggleAgent(agent: LinkedAgentDraft, selected: 'discard' | 'deploy') {
|
|
if (selected === 'discard') {
|
|
selectedAgents = selectedAgents.filter((a) => a.path !== agent.path)
|
|
} else if (!selectedAgents.some((a) => a.path === agent.path)) {
|
|
selectedAgents = [...selectedAgents, agent]
|
|
}
|
|
}
|
|
|
|
function checkSavePermissions(trigger: Trigger) {
|
|
// Creating http trigger is forbidden for non-admin users
|
|
const adminOnly =
|
|
trigger.type === 'http' &&
|
|
!$userStore?.is_admin &&
|
|
!$userStore?.is_super_admin &&
|
|
trigger.isDraft
|
|
|
|
const invalidConfig = !trigger.draftConfig?.canSave
|
|
|
|
return invalidConfig ? 'invalid-config' : adminOnly ? 'admin-only' : 'deploy'
|
|
}
|
|
|
|
function checkAgentPermissions(agent: LinkedAgentDraft): {
|
|
state: 'deploy' | 'read-only' | 'invalid-config'
|
|
reason?: string
|
|
} {
|
|
if (agentCanWrite[agent.path] === false) {
|
|
return { state: 'read-only' }
|
|
}
|
|
const refusal = agentRefusal[agent.path]
|
|
return refusal ? { state: 'invalid-config', reason: refusal } : { state: 'deploy' }
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!open) return
|
|
selectedTriggers = [...draftTriggers].filter((t) => checkSavePermissions(t) === 'deploy')
|
|
selectedAgents = [...draftAgents].filter((a) => checkAgentPermissions(a).state === 'deploy')
|
|
})
|
|
|
|
const runnable = $derived(isFlow ? 'flow' : 'script')
|
|
|
|
// Named after what is actually listed, so the title is not a second, vaguer copy of the section
|
|
// headings below it. "Unsaved changes detected" is taken by the leave-the-page guard, which means
|
|
// the opposite of this dialog: there, unlisted work is about to be lost.
|
|
const title = $derived.by(() => {
|
|
const triggers = draftTriggers.length > 0
|
|
const agents = draftAgents.length > 0
|
|
if (triggers && agents) return 'Draft triggers and agents detected'
|
|
if (agents) return 'Draft agents detected'
|
|
return 'Draft triggers detected'
|
|
})
|
|
</script>
|
|
|
|
<ConfirmationModal
|
|
{open}
|
|
{title}
|
|
confirmationText={isFlow ? 'Deploy Flow' : 'Deploy Script'}
|
|
type="reload"
|
|
showIcon={false}
|
|
on:canceled={() => dispatch('canceled')}
|
|
on:confirmed={() => dispatch('confirmed', { selectedTriggers, selectedAgents })}
|
|
>
|
|
<div class="flex flex-col w-full gap-8 pb-4">
|
|
{#if draftTriggers.length > 0}
|
|
<div class="flex flex-col gap-2">
|
|
<div class="text-secondary text-sm">
|
|
{`Your ${runnable} has draft triggers. Select which draft triggers to deploy with the ${runnable}. Undeployed draft triggers will be permanently deleted.`}
|
|
</div>
|
|
|
|
<div class={draftTriggers.length > 5 ? 'h-[300px]' : ''}>
|
|
<DataTable size="sm" tableFixed={true}>
|
|
<thead>
|
|
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
|
|
<th class="text-left py-2 px-4">Triggers to deploy</th>
|
|
<th class="w-32 text-center py-2 px-1 justify-center"> </th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each draftTriggers as trigger}
|
|
{@const SvelteComponent = triggerIconMap[trigger.type]}
|
|
{@const permission = checkSavePermissions(trigger)}
|
|
{@const isSelectedTrigger = isSelected(selectedTriggers, trigger)}
|
|
<tr
|
|
class={twMerge(
|
|
'transition-colors h-12 border-t border-gray-200 dark:border-gray-700 whitespace-nowrap',
|
|
permission === 'deploy' ? 'hover:bg-surface-hover ' : ''
|
|
)}
|
|
>
|
|
<td class={twMerge('text-center py-1 px-4')}>
|
|
<div class="flex flex-row items-center gap-2">
|
|
<div class="relative flex justify-center items-center">
|
|
<SvelteComponent
|
|
size={14}
|
|
class={isSelectedTrigger ? 'text-accent' : 'text-hint'}
|
|
/>
|
|
{#if trigger.isPrimary}
|
|
<Star size={8} class="absolute -mt-3 ml-3 text-accent" />
|
|
{/if}
|
|
</div>
|
|
<!-- `TriggerLabel` emits its badges as bare siblings, so the gap between them
|
|
is the container's to set, as the other call sites do. -->
|
|
<div class="flex grow min-w-0 items-center gap-2 text-left">
|
|
<TriggerLabel {trigger} discard={!isSelectedTrigger} />
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td class="text-left py-1">
|
|
{#if permission === 'deploy'}
|
|
<div class="flex justify-start">
|
|
<ToggleButtonGroup
|
|
class="w-fit h-fit"
|
|
selected={isSelectedTrigger ? 'deploy' : 'discard'}
|
|
on:selected={(e) => toggleTrigger(trigger, e.detail)}
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton
|
|
label={!trigger.isDraft && trigger.draftConfig ? 'Reset' : 'Discard'}
|
|
value={'discard'}
|
|
{item}
|
|
small
|
|
class="data-[state=on]:text-white data-[state=on]:bg-red-400 justify-center"
|
|
/>
|
|
<ToggleButton
|
|
label={!trigger.isDraft && trigger.draftConfig ? 'Update' : 'Deploy'}
|
|
value={'deploy'}
|
|
{item}
|
|
small
|
|
class="data-[state=on]:bg-surface-accent-primary data-[state=on]:text-white justify-center"
|
|
/>
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
{:else if permission === 'admin-only'}
|
|
<Badge color="red">Admin only</Badge>
|
|
{:else if permission === 'invalid-config'}
|
|
<Badge color="red">Invalid config</Badge>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</DataTable>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if draftAgents.length > 0}
|
|
<div class="flex flex-col gap-2">
|
|
<div class="text-secondary text-sm">
|
|
Saved agents this flow uses have unsaved changes. Select which ones to deploy with the
|
|
flow. An agent kept as a draft stays editable, and the flow runs the agent as currently
|
|
deployed.
|
|
</div>
|
|
|
|
<div class={draftAgents.length > 5 ? 'h-[300px]' : ''}>
|
|
<DataTable size="sm" tableFixed={true}>
|
|
<thead>
|
|
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
|
|
<th class="text-left py-2 px-4">Agents to deploy</th>
|
|
<!-- Wider than the trigger table's: "Keep as draft" does not fit its w-32. -->
|
|
<th class="w-48 text-center py-2 px-1 justify-center"> </th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each draftAgents as agent (agent.path)}
|
|
{@const permission = checkAgentPermissions(agent)}
|
|
{@const isSelectedAgent = selectedAgents.some((a) => a.path === agent.path)}
|
|
<tr
|
|
class={twMerge(
|
|
'transition-colors border-t border-gray-200 dark:border-gray-700',
|
|
permission.state === 'deploy' ? 'hover:bg-surface-hover ' : ''
|
|
)}
|
|
>
|
|
<!-- No fixed height, unlike the trigger table's `h-12` row: a never-deployed agent
|
|
carries a second line of warning under the path, so the row grows with it. -->
|
|
<td class="py-2 px-4">
|
|
<div class="flex flex-row items-center gap-2">
|
|
<Bot size={14} class={isSelectedAgent ? 'text-accent' : 'text-hint'} />
|
|
<div class="flex grow min-w-0 flex-col text-left">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<!-- Dimmed rather than struck through: keeping a draft destroys nothing, so the
|
|
trigger table's deletion styling would say the wrong thing. -->
|
|
<span
|
|
class={twMerge(
|
|
'truncate text-xs',
|
|
isSelectedAgent ? '' : 'text-tertiary'
|
|
)}
|
|
title={agent.path}
|
|
>
|
|
{agent.path}
|
|
</span>
|
|
{#if agent.noDeployed}
|
|
<Badge small color="indigo">Never deployed</Badge>
|
|
{/if}
|
|
</div>
|
|
{#if agent.noDeployed && !isSelectedAgent}
|
|
<!-- The section's promise — the flow falls back to the deployed agent — does not
|
|
hold for one that has never been deployed. There is nothing to fall back to,
|
|
so the flow would land naming a path that does not resolve. -->
|
|
<span class="text-xs text-red-600 dark:text-red-400 whitespace-normal">
|
|
Never deployed, so the flow will not run until this agent is deployed.
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Right-aligned, unlike the trigger column: this one is `w-48` to fit "Keep as
|
|
draft", so left-aligned content would float in the slack instead of lining up
|
|
with the trigger rows above it. -->
|
|
<td class="text-right py-1 pr-2">
|
|
{#if permission.state === 'deploy'}
|
|
<div class="flex justify-end">
|
|
<ToggleButtonGroup
|
|
class="w-fit h-fit"
|
|
selected={isSelectedAgent ? 'deploy' : 'discard'}
|
|
on:selected={(e) => toggleAgent(agent, e.detail)}
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton
|
|
label="Keep as draft"
|
|
value={'discard'}
|
|
{item}
|
|
small
|
|
class="justify-center"
|
|
/>
|
|
<ToggleButton
|
|
label={agent.noDeployed ? 'Deploy' : 'Update'}
|
|
value={'deploy'}
|
|
{item}
|
|
small
|
|
class="data-[state=on]:bg-surface-accent-primary data-[state=on]:text-white justify-center"
|
|
/>
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
{:else if permission.state === 'read-only'}
|
|
<Badge color="red" title="You do not have write access to this agent">
|
|
Read-only
|
|
</Badge>
|
|
{:else}
|
|
<Badge color="red" title={permission.reason}>Invalid config</Badge>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</DataTable>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</ConfirmationModal>
|