Files
windmill/frontend/src/lib/utils_draft_deploy.test.ts
T
hugocasaandClaude Opus 5 7feaf619cf feat: run a linked AI agent's draft when testing a flow, and offer to deploy it (#10993)
* 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>
2026-09-07 19:04:38 +02:00

192 lines
8.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { draftBaseIsStale, deployDraft } from './utils_draft_deploy'
vi.mock('$lib/gen', () => ({
ScriptService: { getScriptByPath: vi.fn(), createScript: vi.fn() },
FlowService: { getFlowByPath: vi.fn(), createFlow: vi.fn(), updateFlow: vi.fn() },
DraftService: { deleteDraft: vi.fn() },
AppService: {},
VariableService: {},
ResourceService: { getResource: vi.fn(), updateResource: vi.fn(), createResource: vi.fn() },
ScheduleService: {},
HttpTriggerService: {},
WebsocketTriggerService: {},
PostgresTriggerService: {},
KafkaTriggerService: {},
NatsTriggerService: {},
MqttTriggerService: {},
AmqpTriggerService: {},
SqsTriggerService: {},
GcpTriggerService: {},
AzureTriggerService: {},
EmailTriggerService: {}
}))
vi.mock('$lib/userDraftDbSyncer.svelte', () => ({
UserDraftDbSyncer: { save: vi.fn(), recordRemoteSync: vi.fn() }
}))
vi.mock('$lib/workspaceDrafts.svelte', () => ({ invalidateWorkspaceDrafts: vi.fn() }))
vi.mock('$lib/workspaceComparison', () => ({ invalidateWorkspaceComparison: vi.fn() }))
vi.mock('$lib/localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() }))
vi.mock('$lib/rawAppDeploy', () => ({ deployRawAppDraft: vi.fn() }))
vi.mock('$lib/components/raw_apps/utils', () => ({ canonicalRawAppDiffValue: vi.fn() }))
vi.mock('$lib/appDiffSides', () => ({ classicAppDraftParts: vi.fn() }))
vi.mock('$lib/utils_deployable', () => ({ TRIGGER_RUNTIME_IGNORE: [] }))
import { ScriptService, FlowService, ResourceService } from '$lib/gen'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
// draftBaseIsStale compares a draft's base pointer against the deployed head
// of the item it was fetched with (`get_draft=true`). Shared by CompareDrafts
// and the session Edits drawer — a regression here silently hides (or
// fabricates) the "started from an older deployed version" warning.
describe('draftBaseIsStale', () => {
it('script: stale iff the draft parent_hash differs from the deployed hash', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft: { parent_hash: 'v1' } })).toBe(true)
expect(draftBaseIsStale('script', { hash: 'v2', draft: { parent_hash: 'v2' } })).toBe(false)
})
it('script: no base pointer or no head → not stale (nothing to compare)', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft: {} })).toBe(false)
expect(draftBaseIsStale('script', { draft: { parent_hash: 'v1' } })).toBe(false)
})
it('flow: compares the pinned version_id against the deployed head', () => {
expect(draftBaseIsStale('flow', { version_id: 7, draft: { version_id: 5 } })).toBe(true)
expect(draftBaseIsStale('flow', { version_id: 7, draft: { version_id: 7 } })).toBe(false)
expect(draftBaseIsStale('flow', { version_id: 7, draft: {} })).toBe(false)
})
it('app/raw_app: compares parent_version against the last of versions', () => {
expect(draftBaseIsStale('app', { versions: [1, 2, 3], draft: { parent_version: 2 } })).toBe(
true
)
expect(draftBaseIsStale('raw_app', { versions: [1, 2, 3], draft: { parent_version: 3 } })).toBe(
false
)
expect(draftBaseIsStale('app', { versions: [], draft: { parent_version: 2 } })).toBe(false)
})
it('no draft on the response → not stale', () => {
expect(draftBaseIsStale('script', { hash: 'v2' })).toBe(false)
expect(draftBaseIsStale('script', undefined)).toBe(false)
})
})
// Without preserve_on_behalf_of the backend rewrites on_behalf_of_email to the
// deploying user, so deploying a draft silently re-points the runnable's
// identity.
describe('deployDraft preserves on_behalf_of', () => {
beforeEach(() => vi.clearAllMocks())
it('script: forwards the flag when the draft carries an on_behalf_of_email', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
hash: 'v1',
draft: { path: 'f/admin/send_email', on_behalf_of_email: 'alice@windmill.dev' }
} as any)
expect(await deployDraft('script', 'f/admin/send_email', 'ws')).toEqual({ success: true })
expect(ScriptService.createScript).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
on_behalf_of_email: 'alice@windmill.dev',
preserve_on_behalf_of: true
})
})
)
})
it('script: omits the flag when the draft has no on_behalf_of_email', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
hash: 'v1',
draft: { path: 'f/admin/send_email' }
} as any)
await deployDraft('script', 'f/admin/send_email', 'ws')
expect(ScriptService.createScript).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({ preserve_on_behalf_of: undefined })
})
)
})
it('flow: forwards the flag when the draft carries an on_behalf_of_email', async () => {
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
draft: { path: 'f/admin/notify', value: {}, on_behalf_of_email: 'alice@windmill.dev' }
} as any)
expect(await deployDraft('flow', 'f/admin/notify', 'ws')).toEqual({ success: true })
expect(FlowService.updateFlow).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
on_behalf_of_email: 'alice@windmill.dev',
preserve_on_behalf_of: true
})
})
)
})
})
// The resource branch reads the item again when the deploy lands, and falls back to the deployed
// row when the draft has gone. That row keeps its value under `value` and carries no `args` at
// all, so reading it as a draft (`value: d.args ?? {}`) would replace a live resource with `{}`.
describe('deployDraft: resource with no draft', () => {
beforeEach(() => vi.clearAllMocks())
it('writes nothing rather than `{}` over the deployed value', async () => {
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/support/triage_agent',
resource_type: 'ai_agent',
value: { system_prompt: 'deployed' }
} as any)
// `noop` is what lets a caller deploying one specific draft tell "nothing to promote" apart
// from "deployed", instead of reporting an agent as deployed that was never written.
expect(await deployDraft('resource', 'f/support/triage_agent', 'ws')).toEqual({
success: true,
noop: true
})
expect(ResourceService.updateResource).not.toHaveBeenCalled()
expect(ResourceService.createResource).not.toHaveBeenCalled()
// Nor does it touch the draft row. There was none of this user's to delete, so the only row
// the cleanup could reach is one written after the read: an edit destroyed without ever
// having been deployed. Clearing the baseline would be the same bug by another route, since
// a delete with no baseline is the unconditional one.
expect(UserDraftDbSyncer.save).not.toHaveBeenCalled()
expect(UserDraftDbSyncer.recordRemoteSync).not.toHaveBeenCalled()
})
it('still deploys normally when the draft is there, and keys the cleanup to the row it read', async () => {
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/support/triage_agent',
resource_type: 'ai_agent',
value: { system_prompt: 'deployed' },
draft_saved_at: '2026-01-01T00:00:00Z',
draft: { path: 'f/support/triage_agent', args: { system_prompt: 'drafted' } }
} as any)
expect(await deployDraft('resource', 'f/support/triage_agent', 'ws')).toEqual({ success: true })
expect(ResourceService.updateResource).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({ value: { system_prompt: 'drafted' } })
})
)
// The draft delete that follows is conditional on this baseline. With no baseline the backend
// deletes unconditionally, destroying a draft saved between the read and the delete without
// ever having deployed it, so the timestamp has to be the one from the row just promoted.
expect(UserDraftDbSyncer.recordRemoteSync).toHaveBeenCalledWith(
{ workspace: 'ws', itemKind: 'resource', path: 'f/support/triage_agent' },
'2026-01-01T00:00:00Z'
)
expect(UserDraftDbSyncer.save).toHaveBeenCalledWith(
expect.objectContaining({ path: 'f/support/triage_agent', value: null, immediate: true })
)
// Order is the whole point: a delete issued before the seed carries whatever baseline the tab
// happened to hold, which for a caller that only read through a listing is none at all.
expect(vi.mocked(UserDraftDbSyncer.recordRemoteSync).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(UserDraftDbSyncer.save).mock.invocationCallOrder[0]
)
})
})