mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
fix: preserve metadata, renames, and discard order for ai chat drafts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -714,6 +714,80 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a draft-only DB script anchor (no draft row) as a draft', async () => {
|
||||
// A bare draft_only anchor with no draft row resolves to source 'deployed'
|
||||
// (its own value is the only version), yet it IS a draft.
|
||||
__seedDbRow('script', 'f/scripts/draft-only', {
|
||||
path: 'f/scripts/draft-only',
|
||||
hash: 'draft-anchor-hash',
|
||||
draft_only: true,
|
||||
summary: 'draft-only summary',
|
||||
description: 'draft-only description',
|
||||
content: 'draft-only content',
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/draft-only'
|
||||
})
|
||||
expect(JSON.parse(raw)).toEqual({
|
||||
type: 'script',
|
||||
path: 'f/scripts/draft-only',
|
||||
summary: 'draft-only summary',
|
||||
language: 'bun',
|
||||
value: 'draft-only content',
|
||||
isDraft: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a draft-only DB flow anchor (no draft row) as a draft', async () => {
|
||||
__seedDbRow('flow', 'f/flows/draft-only', {
|
||||
path: 'f/flows/draft-only',
|
||||
draft_only: true,
|
||||
summary: 'draft-only flow summary',
|
||||
value: { modules: [{ id: 'draft_only_step', value: { type: 'identity' } }] },
|
||||
schema: { type: 'object', properties: { draftOnly: { type: 'boolean' } } }
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'flow',
|
||||
path: 'f/flows/draft-only'
|
||||
})
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
type: 'flow',
|
||||
path: 'f/flows/draft-only',
|
||||
summary: 'draft-only flow summary',
|
||||
isDraft: true,
|
||||
value: {
|
||||
modules: [{ id: 'draft_only_step', value: { type: 'identity' } }],
|
||||
schema: { type: 'object', properties: { draftOnly: { type: 'boolean' } } }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a draft-only DB app anchor (no draft row) as a draft', async () => {
|
||||
// The app anchor's `value` nests the raw app the way appSourceToDraftValue reads.
|
||||
__seedDbRow('app', 'f/apps/draft-only', {
|
||||
path: 'f/apps/draft-only',
|
||||
draft_only: true,
|
||||
summary: 'draft-only app summary',
|
||||
value: { files: { '/index.tsx': 'console.log("draft")' }, runnables: {}, data: { tables: [] } }
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'app',
|
||||
path: 'f/apps/draft-only'
|
||||
})
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
type: 'app',
|
||||
path: 'f/apps/draft-only',
|
||||
summary: 'draft-only app summary',
|
||||
isDraft: true
|
||||
})
|
||||
})
|
||||
|
||||
it('applies path_prefix to local (live-editor) drafts before enforcing the result limit', async () => {
|
||||
// list_workspace_items still surfaces live-editor localStorage drafts, so
|
||||
// drive it through live editors rather than DB-only writes.
|
||||
@@ -828,6 +902,47 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('list_workspace_items shows the fresh DB draft summary, not the stale anchor', async () => {
|
||||
// The draft_only anchor is intentionally NOT updated on a re-save, so its
|
||||
// list summary goes stale. A newer DB draft exists; the list must surface
|
||||
// the draft's summary and flag the row as a draft.
|
||||
__seedDbRow('script', 'f/scripts/stale-list-summary', {
|
||||
path: 'f/scripts/stale-list-summary',
|
||||
draft_only: true,
|
||||
draft: {
|
||||
path: 'f/scripts/stale-list-summary',
|
||||
summary: 'Fresh DB draft summary',
|
||||
description: '',
|
||||
content: 'export async function main() { return "fresh" }',
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
}
|
||||
})
|
||||
vi.mocked(ScriptService.listScripts).mockResolvedValueOnce([
|
||||
{
|
||||
path: 'f/scripts/stale-list-summary',
|
||||
summary: 'Old deployed summary',
|
||||
language: 'bun',
|
||||
has_draft: true
|
||||
}
|
||||
] as any)
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script'],
|
||||
query: 'Fresh DB draft'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'f/scripts/stale-list-summary',
|
||||
summary: 'Fresh DB draft summary',
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
expect(raw).not.toContain('Old deployed summary')
|
||||
})
|
||||
|
||||
it('lists and edits the live script editor draft through its effective path', async () => {
|
||||
UserDraft.save(
|
||||
'script',
|
||||
@@ -1073,8 +1188,41 @@ describe('global AI tools', () => {
|
||||
})
|
||||
expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/scripts/db-only'
|
||||
path: 'f/scripts/db-only',
|
||||
keepCaptures: true
|
||||
})
|
||||
// Anchor deletion happens BEFORE the draft row deletion (failure-resilient).
|
||||
expect(vi.mocked(ScriptService.deleteScriptByPath).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(DraftService.deleteDraft).mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the DB draft when draft_only anchor deletion fails', async () => {
|
||||
// A draft_only script whose anchor delete is rejected (e.g. deployment rules).
|
||||
// Because the anchor is deleted first, the draft row must remain untouched.
|
||||
__seedDbRow('script', 'f/scripts/blocked-discard', {
|
||||
path: 'f/scripts/blocked-discard',
|
||||
draft_only: true,
|
||||
draft: {
|
||||
path: 'f/scripts/blocked-discard',
|
||||
summary: 'Temporary draft',
|
||||
content: 'export async function main() { return 1 }',
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
}
|
||||
})
|
||||
vi.mocked(ScriptService.deleteScriptByPath).mockRejectedValueOnce(
|
||||
new Error('deployment rules blocked deletion')
|
||||
)
|
||||
|
||||
await expect(
|
||||
callGlobalTool('discard_draft', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/blocked-discard'
|
||||
})
|
||||
).rejects.toThrow('deployment rules blocked deletion')
|
||||
|
||||
expect(DraftService.deleteDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards a DB draft over a deployed flow without deleting the deployed item', async () => {
|
||||
@@ -1126,7 +1274,8 @@ describe('global AI tools', () => {
|
||||
})
|
||||
expect(FlowService.deleteFlowByPath).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/flows/db-only'
|
||||
path: 'f/flows/db-only',
|
||||
keepCaptures: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1921,6 +2070,181 @@ describe('global AI tools', () => {
|
||||
expect(DraftService.deleteDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deploys DB script drafts with the draft metadata (not the deployed version)', async () => {
|
||||
// A deployed script with a DB draft whose metadata (tag/timeout/labels)
|
||||
// differs. The deploy must carry the DRAFT's metadata, parent on the
|
||||
// deployed hash, and re-infer the schema from the draft content.
|
||||
__seedDbRow('script', 'f/scripts/existing', {
|
||||
path: 'f/scripts/existing',
|
||||
hash: 'deployed-hash',
|
||||
draft_only: false,
|
||||
summary: 'deployed summary',
|
||||
description: 'deployed description',
|
||||
content: 'deployed content',
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
tag: 'deployed-tag',
|
||||
envs: ['DEPLOYED_ENV'],
|
||||
timeout: 60,
|
||||
visible_to_runner_only: true,
|
||||
labels: ['deployed'],
|
||||
draft: {
|
||||
path: 'f/scripts/existing',
|
||||
summary: 'db draft summary',
|
||||
description: 'db draft description',
|
||||
content: 'export async function main(x: number) { return x }',
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
tag: 'draft-tag',
|
||||
envs: [],
|
||||
timeout: 0,
|
||||
visible_to_runner_only: false,
|
||||
labels: []
|
||||
}
|
||||
})
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/existing',
|
||||
deployment_message: 'ship db draft'
|
||||
})
|
||||
|
||||
expect(ScriptService.createScript).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: expect.objectContaining({
|
||||
path: 'f/scripts/existing',
|
||||
parent_hash: 'deployed-hash',
|
||||
summary: 'db draft summary',
|
||||
description: 'db draft description',
|
||||
content: 'export async function main(x: number) { return x }',
|
||||
language: 'bun',
|
||||
tag: 'draft-tag',
|
||||
envs: [],
|
||||
timeout: 0,
|
||||
visible_to_runner_only: false,
|
||||
labels: [],
|
||||
deployment_message: 'ship db draft'
|
||||
})
|
||||
})
|
||||
// Schema is re-inferred from the draft content.
|
||||
const body = vi.mocked(ScriptService.createScript).mock.calls[0][0].requestBody as any
|
||||
expect(body.schema?.properties).toHaveProperty('x')
|
||||
expect(DraftService.deleteDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deploys a renamed script draft to the draft target path', async () => {
|
||||
// The DB draft carries a renamed path; deploy must target that path and
|
||||
// clean up the stale old-lookup-path draft + draft_only anchor.
|
||||
__seedDbRow('script', 'f/scripts/old-name', {
|
||||
path: 'f/scripts/old-name',
|
||||
hash: 'old-script-hash',
|
||||
draft_only: false,
|
||||
summary: 'deployed summary',
|
||||
description: 'deployed description',
|
||||
content: 'deployed content',
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
draft: {
|
||||
path: 'f/scripts/new-name',
|
||||
summary: 'renamed draft summary',
|
||||
description: 'renamed draft description',
|
||||
content: 'renamed draft content',
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
}
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/old-name'
|
||||
})
|
||||
|
||||
expect(ScriptService.createScript).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: expect.objectContaining({
|
||||
path: 'f/scripts/new-name',
|
||||
parent_hash: 'old-script-hash',
|
||||
summary: 'renamed draft summary',
|
||||
description: 'renamed draft description',
|
||||
content: 'renamed draft content'
|
||||
})
|
||||
})
|
||||
// Deployed version existed -> the old anchor is NOT deleted, only the stale
|
||||
// draft row at the old path.
|
||||
expect(ScriptService.deleteScriptByPath).not.toHaveBeenCalled()
|
||||
expect(DraftService.deleteDraft).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
kind: 'script',
|
||||
path: 'f/scripts/old-name'
|
||||
})
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
success: true,
|
||||
type: 'script',
|
||||
path: 'f/scripts/new-name'
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the old draft_only anchor when deploying a renamed draft-only script', async () => {
|
||||
// A never-deployed (draft_only) script renamed in its draft. Deploy targets
|
||||
// the new path and must remove the stale old-path draft AND its anchor.
|
||||
__seedDbRow('script', 'f/scripts/old-only', {
|
||||
path: 'f/scripts/old-only',
|
||||
draft_only: true,
|
||||
draft: {
|
||||
path: 'f/scripts/new-only',
|
||||
summary: 'renamed draft-only summary',
|
||||
description: '',
|
||||
content: 'export async function main() { return 1 }',
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
}
|
||||
})
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/old-only'
|
||||
})
|
||||
|
||||
expect(ScriptService.createScript).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: expect.objectContaining({ path: 'f/scripts/new-only' })
|
||||
})
|
||||
expect(DraftService.deleteDraft).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
kind: 'script',
|
||||
path: 'f/scripts/old-only'
|
||||
})
|
||||
expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/scripts/old-only',
|
||||
keepCaptures: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT call deleteDraft on a non-rename deploy (the backend handles it)', async () => {
|
||||
__seedDbRow('script', 'f/scripts/same-path', {
|
||||
path: 'f/scripts/same-path',
|
||||
draft_only: true,
|
||||
draft: {
|
||||
path: 'f/scripts/same-path',
|
||||
summary: 'DB draft',
|
||||
description: '',
|
||||
content: 'export async function main() { return 1 }',
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
}
|
||||
})
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/same-path'
|
||||
})
|
||||
|
||||
expect(ScriptService.createScript).toHaveBeenCalledTimes(1)
|
||||
expect(DraftService.deleteDraft).not.toHaveBeenCalled()
|
||||
expect(ScriptService.deleteScriptByPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('errors when deploying a script that has only a deployed version (no draft)', async () => {
|
||||
__seedDbRow('script', 'f/scripts/deployed-only', {
|
||||
path: 'f/scripts/deployed-only',
|
||||
@@ -2033,6 +2357,110 @@ describe('global AI tools', () => {
|
||||
expect(DraftService.deleteDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deploys DB flow drafts with the draft metadata (not the deployed version)', async () => {
|
||||
vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true)
|
||||
__seedDbRow('flow', 'f/flows/existing', {
|
||||
path: 'f/flows/existing',
|
||||
draft_only: false,
|
||||
summary: 'deployed summary',
|
||||
description: 'deployed description',
|
||||
value: { modules: [{ id: 'deployed_step', value: { type: 'identity' } }] },
|
||||
schema: { type: 'object', properties: { deployed: { type: 'boolean' } } },
|
||||
tag: 'deployed-tag',
|
||||
timeout: 60,
|
||||
visible_to_runner_only: true,
|
||||
labels: ['deployed'],
|
||||
draft: {
|
||||
path: 'f/flows/existing',
|
||||
summary: 'db draft summary',
|
||||
description: 'db draft description',
|
||||
value: { modules: [{ id: 'draft_step', value: { type: 'identity' } }] },
|
||||
schema: { type: 'object', properties: { draft: { type: 'string' } } },
|
||||
tag: 'draft-tag',
|
||||
timeout: 0,
|
||||
visible_to_runner_only: false,
|
||||
labels: []
|
||||
}
|
||||
})
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'flow',
|
||||
path: 'f/flows/existing',
|
||||
deployment_message: 'ship db draft'
|
||||
})
|
||||
|
||||
expect(FlowService.updateFlow).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/flows/existing',
|
||||
requestBody: expect.objectContaining({
|
||||
path: 'f/flows/existing',
|
||||
summary: 'db draft summary',
|
||||
description: 'db draft description',
|
||||
schema: { type: 'object', properties: { draft: { type: 'string' } } },
|
||||
tag: 'draft-tag',
|
||||
timeout: 0,
|
||||
visible_to_runner_only: false,
|
||||
labels: [],
|
||||
deployment_message: 'ship db draft'
|
||||
})
|
||||
})
|
||||
expect(vi.mocked(FlowService.updateFlow).mock.calls[0]?.[0].requestBody.value.modules).toEqual([
|
||||
{ id: 'draft_step', value: { type: 'identity' } }
|
||||
])
|
||||
expect(DraftService.deleteDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deploys a renamed flow draft through the original route path', async () => {
|
||||
vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true)
|
||||
__seedDbRow('flow', 'f/flows/old-name', {
|
||||
path: 'f/flows/old-name',
|
||||
draft_only: false,
|
||||
summary: 'deployed summary',
|
||||
description: 'deployed description',
|
||||
value: { modules: [{ id: 'deployed_step', value: { type: 'identity' } }] },
|
||||
schema: { type: 'object', properties: { deployed: { type: 'boolean' } } },
|
||||
draft: {
|
||||
path: 'f/flows/new-name',
|
||||
summary: 'renamed draft summary',
|
||||
description: 'renamed draft description',
|
||||
value: { modules: [{ id: 'draft_step', value: { type: 'identity' } }] },
|
||||
schema: { type: 'object', properties: { draft: { type: 'string' } } }
|
||||
}
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'flow',
|
||||
path: 'f/flows/old-name'
|
||||
})
|
||||
|
||||
// Original path in the URL, renamed path in the body.
|
||||
expect(FlowService.updateFlow).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/flows/old-name',
|
||||
requestBody: expect.objectContaining({
|
||||
path: 'f/flows/new-name',
|
||||
summary: 'renamed draft summary',
|
||||
description: 'renamed draft description',
|
||||
schema: { type: 'object', properties: { draft: { type: 'string' } } }
|
||||
})
|
||||
})
|
||||
expect(vi.mocked(FlowService.updateFlow).mock.calls[0]?.[0].requestBody.value.modules).toEqual([
|
||||
{ id: 'draft_step', value: { type: 'identity' } }
|
||||
])
|
||||
// Deployed version existed -> only the stale old-path draft is removed.
|
||||
expect(FlowService.deleteFlowByPath).not.toHaveBeenCalled()
|
||||
expect(DraftService.deleteDraft).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
kind: 'flow',
|
||||
path: 'f/flows/old-name'
|
||||
})
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
success: true,
|
||||
type: 'flow',
|
||||
path: 'f/flows/new-name'
|
||||
})
|
||||
})
|
||||
|
||||
it('errors when deploying a flow that has only a deployed version (no draft)', async () => {
|
||||
__seedDbRow('flow', 'f/flows/deployed-only', {
|
||||
path: 'f/flows/deployed-only',
|
||||
@@ -2162,6 +2590,68 @@ describe('global AI tools', () => {
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deploys a renamed app draft through the original route path', async () => {
|
||||
vi.mocked(AppService.existsApp).mockResolvedValueOnce(true)
|
||||
// A deployed app whose DB draft carries a renamed path. The draft `value`
|
||||
// nests the raw app + the renamed `path`, the way appSourceToDraftValue reads.
|
||||
__seedDbRow('app', 'f/apps/old-report', {
|
||||
path: 'f/apps/old-report',
|
||||
summary: 'deployed report',
|
||||
versions: [1],
|
||||
value: { files: {}, runnables: {}, data: { tables: [] } },
|
||||
policy: { execution_mode: 'anonymous' },
|
||||
draft: {
|
||||
path: 'f/apps/new-report',
|
||||
summary: 'Renamed report',
|
||||
value: {
|
||||
files: { '/index.tsx': 'console.log("renamed")' },
|
||||
runnables: {},
|
||||
data: { tables: ['renamed'] }
|
||||
},
|
||||
policy: { execution_mode: 'anonymous' }
|
||||
}
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'app',
|
||||
path: 'f/apps/old-report'
|
||||
})
|
||||
|
||||
// Original path in the URL, renamed path in the body.
|
||||
expect(AppService.updateAppRaw).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/apps/old-report',
|
||||
formData: {
|
||||
app: {
|
||||
path: 'f/apps/new-report',
|
||||
value: {
|
||||
files: { '/index.tsx': 'console.log("renamed")' },
|
||||
runnables: {},
|
||||
data: { tables: ['renamed'] }
|
||||
},
|
||||
summary: 'Renamed report',
|
||||
policy: expect.objectContaining({ execution_mode: 'anonymous' }),
|
||||
deployment_message: undefined
|
||||
},
|
||||
js: 'bundled js',
|
||||
css: 'bundled css'
|
||||
}
|
||||
})
|
||||
expect(AppService.createAppRaw).not.toHaveBeenCalled()
|
||||
// Deployed version existed -> only the stale old-path draft is removed.
|
||||
expect(AppService.deleteApp).not.toHaveBeenCalled()
|
||||
expect(DraftService.deleteDraft).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
kind: 'app',
|
||||
path: 'f/apps/old-report'
|
||||
})
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
success: true,
|
||||
type: 'app',
|
||||
path: 'f/apps/new-report'
|
||||
})
|
||||
})
|
||||
|
||||
it('notifies the session preview (as raw_app) after deploying a raw app', async () => {
|
||||
const onDeployed = vi.fn()
|
||||
setDeployedInSessionHandler(onDeployed)
|
||||
|
||||
@@ -95,7 +95,6 @@ import {
|
||||
type WorkspaceItem,
|
||||
type WorkspaceItemType
|
||||
} from './workspaceItems'
|
||||
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
import { bundleRawAppDraft } from './rawAppBundlerBridge'
|
||||
@@ -1182,7 +1181,9 @@ async function readWorkspaceItem(
|
||||
path,
|
||||
summary: loaded.value.summary,
|
||||
value: metadata as unknown as AppDraftValue,
|
||||
isDraft: loaded.source === 'live' || loaded.source === 'db'
|
||||
// A bare `draft_only` anchor (no draft row) reads as 'deployed' but is
|
||||
// a draft — flag it via `draftOnly` too.
|
||||
isDraft: loaded.source === 'live' || loaded.source === 'db' || loaded.draftOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1202,18 +1203,45 @@ async function readScriptOrFlowItem(
|
||||
// Call `loadDraft` with the literal type in each branch so its return value is
|
||||
// narrowed to NewScript / Flow (a `'script' | 'flow'` arg would leave it a
|
||||
// `NewScript | Flow` union that neither converter accepts).
|
||||
// A bare `draft_only` anchor with no draft row reads `source: 'deployed'`
|
||||
// (the anchor's own value is the only version), yet it IS a draft — flag it.
|
||||
if (type === 'script') {
|
||||
const loaded = await loadDraft('script', path, workspace)
|
||||
if (!loaded.value) throw new Error(`Script "${path}" not found.`)
|
||||
const isDraft = loaded.source === 'live' || loaded.source === 'db'
|
||||
const isDraft = loaded.source === 'live' || loaded.source === 'db' || loaded.draftOnly
|
||||
return { ...scriptToWorkspaceItem(path, loaded.value), isDraft }
|
||||
}
|
||||
const loaded = await loadDraft('flow', path, workspace)
|
||||
if (!loaded.value) throw new Error(`Flow "${path}" not found.`)
|
||||
const isDraft = loaded.source === 'live' || loaded.source === 'db'
|
||||
const isDraft = loaded.source === 'live' || loaded.source === 'db' || loaded.draftOnly
|
||||
return { ...flowToWorkspaceItem(path, loaded.value), isDraft }
|
||||
}
|
||||
|
||||
/**
|
||||
* The list endpoints return the row from the item table — whose summary is the
|
||||
* deployed (or, for a `draft_only` anchor, the original) one. The anchor is
|
||||
* intentionally NOT updated on a re-save, so a row with a live DB draft can show
|
||||
* a stale summary. For rows flagged as carrying a draft (`has_draft`/`draft_only`)
|
||||
* we re-resolve summary/metadata through `loadDraft` (precedence-aware, so a live
|
||||
* editor's unsaved summary wins), keeping the listed item metadata-only
|
||||
* (`value: undefined`) and `isDraft: true`.
|
||||
*/
|
||||
async function hydrateListedDbDraft(
|
||||
type: 'script' | 'flow',
|
||||
item: WorkspaceItem,
|
||||
workspace: string
|
||||
): Promise<WorkspaceItem> {
|
||||
try {
|
||||
const loaded = await loadDraft(type, item.path, workspace)
|
||||
if (!loaded.value) return item
|
||||
const isDraft = loaded.source === 'live' || loaded.source === 'db' || loaded.draftOnly
|
||||
return { ...item, summary: loaded.value.summary, value: undefined, isDraft }
|
||||
} catch {
|
||||
// A transient read failure for one row must not sink the whole list.
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
async function listWorkspaceItems(
|
||||
types: WorkspaceItemType[],
|
||||
workspace: string,
|
||||
@@ -1230,7 +1258,14 @@ async function listWorkspaceItems(
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
for (const script of scripts) items.push(scriptToItem(script, false))
|
||||
for (const script of scripts) {
|
||||
const item = scriptToItem(script, false)
|
||||
items.push(
|
||||
script.has_draft || script.draft_only
|
||||
? await hydrateListedDbDraft('script', item, workspace)
|
||||
: item
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (types.includes('flow')) {
|
||||
@@ -1241,7 +1276,14 @@ async function listWorkspaceItems(
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
for (const flow of flows) items.push(flowToItem(flow, false))
|
||||
for (const flow of flows) {
|
||||
const item = flowToItem(flow, false)
|
||||
items.push(
|
||||
flow.has_draft || flow.draft_only
|
||||
? await hydrateListedDbDraft('flow', item, workspace)
|
||||
: item
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (types.includes('schedule')) {
|
||||
@@ -1288,6 +1330,10 @@ async function listWorkspaceItems(
|
||||
pathStart: pathPrefix,
|
||||
perPage
|
||||
})
|
||||
// The generated `ListableApp` exposes no `has_draft`/`draft_only` flags, so
|
||||
// we can't bound an app-draft hydration the way scripts/flows do. Accept a
|
||||
// possibly-stale app summary here for now; client-side regeneration of those
|
||||
// flags (or a per-app draft read) is the follow-up.
|
||||
for (const app of apps) items.push(appToItem(app, false))
|
||||
}
|
||||
|
||||
@@ -3184,22 +3230,26 @@ async function discardDraft(
|
||||
throw new Error(`No draft found for ${type} "${path}".`)
|
||||
}
|
||||
|
||||
if (db.hasDbDraft) {
|
||||
await DraftService.deleteDraft({ workspace, kind: type, path })
|
||||
}
|
||||
|
||||
// Only delete the item when it exists purely as a draft. A deployed version
|
||||
// must survive the discard untouched.
|
||||
// Order matters: delete the `draft_only` anchor FIRST, then the draft row.
|
||||
// Deployment rules can reject the anchor delete (it throws); doing it first
|
||||
// means a failure leaves the draft row intact and recoverable. Only delete
|
||||
// the item when it exists purely as a draft — a deployed version must
|
||||
// survive the discard untouched. `keepCaptures` mirrors the editors so a
|
||||
// later re-create keeps its capture history.
|
||||
if (db.draftOnly) {
|
||||
if (type === 'script') {
|
||||
await ScriptService.deleteScriptByPath({ workspace, path })
|
||||
await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true })
|
||||
} else if (type === 'flow') {
|
||||
await FlowService.deleteFlowByPath({ workspace, path })
|
||||
await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true })
|
||||
} else {
|
||||
await AppService.deleteApp({ workspace, path })
|
||||
}
|
||||
}
|
||||
|
||||
if (db.hasDbDraft) {
|
||||
await DraftService.deleteDraft({ workspace, kind: type, path })
|
||||
}
|
||||
|
||||
// Full clear of the live-editor mirror — the user is discarding.
|
||||
deleteGlobalDraft(workspace, type, path)
|
||||
|
||||
@@ -3283,6 +3333,15 @@ async function deployDraft(
|
||||
})
|
||||
|
||||
let actions: ToolDisplayAction[] | undefined
|
||||
// The path the draft is deployed AT. Group-A branches set it to the draft's
|
||||
// own path (honouring a rename); it differs from the lookup `path` only when
|
||||
// the draft was renamed. The create/update body, success message, post-deploy
|
||||
// cleanup and the session-preview reload all key off this.
|
||||
let deployPath = path
|
||||
// Whether the Group-A item existed only as a `draft_only` anchor (no deployed
|
||||
// version). On a rename deploy this decides whether the stale old-path anchor
|
||||
// needs explicit cleanup.
|
||||
let oldDraftOnly = false
|
||||
|
||||
switch (type) {
|
||||
case 'script': {
|
||||
@@ -3293,15 +3352,54 @@ async function deployDraft(
|
||||
if (loaded.source === 'deployed') {
|
||||
throw new Error(`No draft changes to deploy for script "${path}".`)
|
||||
}
|
||||
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
|
||||
? await ScriptService.getScriptByPath({ workspace, path })
|
||||
: undefined
|
||||
const requestBody = buildScriptDeployRequestBody(
|
||||
path,
|
||||
scriptToWorkspaceItem(path, loaded.value),
|
||||
existing,
|
||||
deploymentMessage
|
||||
)
|
||||
deployPath = loaded.value.path ?? path
|
||||
oldDraftOnly = loaded.draftOnly
|
||||
// Mirror ScriptBuilder's createScript body field-for-field, sourced from
|
||||
// the in-memory draft (`loaded.value` is the full NewScript). A raw spread
|
||||
// would leak the DB draft's stray `draft_triggers`/`draft_only`/`hash`,
|
||||
// so we list the fields explicitly. The deploy parents on the deployed
|
||||
// hash (`meta.remoteRev`) without an extra fetch.
|
||||
const draft = loaded.value
|
||||
const requestBody: NewScript = {
|
||||
path: deployPath,
|
||||
summary: draft.summary ?? '',
|
||||
description: draft.description ?? '',
|
||||
content: draft.content,
|
||||
// `meta.remoteRev` is the deployed script hash (a string); fall back to
|
||||
// any parent already carried on the draft.
|
||||
parent_hash: (loaded.meta.remoteRev as string | undefined) ?? draft.parent_hash,
|
||||
schema: draft.schema,
|
||||
is_template: draft.is_template,
|
||||
language: draft.language,
|
||||
kind: draft.kind,
|
||||
tag: draft.tag,
|
||||
envs: draft.envs,
|
||||
dedicated_worker: draft.dedicated_worker,
|
||||
concurrent_limit: draft.concurrent_limit,
|
||||
concurrency_time_window_s: draft.concurrency_time_window_s,
|
||||
debounce_key: draft.debounce_key,
|
||||
debounce_delay_s: draft.debounce_delay_s,
|
||||
debounce_args_to_accumulate: draft.debounce_args_to_accumulate,
|
||||
max_total_debouncing_time: draft.max_total_debouncing_time,
|
||||
max_total_debounces_amount: draft.max_total_debounces_amount,
|
||||
cache_ttl: draft.cache_ttl,
|
||||
cache_ignore_s3_path: draft.cache_ignore_s3_path,
|
||||
ws_error_handler_muted: draft.ws_error_handler_muted,
|
||||
priority: draft.priority,
|
||||
restart_unless_cancelled: draft.restart_unless_cancelled,
|
||||
timeout: draft.timeout,
|
||||
delete_after_secs: draft.delete_after_secs,
|
||||
concurrency_key: draft.concurrency_key,
|
||||
visible_to_runner_only: draft.visible_to_runner_only,
|
||||
auto_kind: draft.auto_kind,
|
||||
has_preprocessor: draft.has_preprocessor,
|
||||
deployment_message: deploymentMessage,
|
||||
on_behalf_of_email: draft.on_behalf_of_email,
|
||||
preserve_on_behalf_of: draft.on_behalf_of_email ? true : undefined,
|
||||
assets: draft.assets,
|
||||
modules: draft.modules,
|
||||
labels: draft.labels
|
||||
}
|
||||
// Infer the arg schema from the content so it matches the code, like the editor does.
|
||||
try {
|
||||
const schema = emptySchema()
|
||||
@@ -3321,17 +3419,34 @@ async function deployDraft(
|
||||
if (loaded.source === 'deployed') {
|
||||
throw new Error(`No draft changes to deploy for flow "${path}".`)
|
||||
}
|
||||
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
|
||||
? await FlowService.getFlowByPath({ workspace, path })
|
||||
: undefined
|
||||
const requestBody = buildFlowDeployRequestBody(
|
||||
path,
|
||||
loaded.value.summary,
|
||||
flowToFlowDraftValue(loaded.value),
|
||||
existing,
|
||||
deploymentMessage
|
||||
)
|
||||
if (existing) {
|
||||
deployPath = loaded.value.path ?? path
|
||||
oldDraftOnly = loaded.draftOnly
|
||||
// Create-vs-update keys off the deployed item at the *original* lookup
|
||||
// path, not the renamed target.
|
||||
const deployedExists = await FlowService.existsFlowByPath({ workspace, path })
|
||||
// Mirror FlowBuilder's update/create body, sourced from the draft Flow.
|
||||
// Backend-only fields (edited_by/edited_at/archived/extra_perms) and the
|
||||
// DB draft's stray fields are excluded by the explicit list.
|
||||
const draft = loaded.value
|
||||
const requestBody = {
|
||||
path: deployPath,
|
||||
summary: draft.summary ?? '',
|
||||
description: draft.description ?? '',
|
||||
value: draft.value,
|
||||
schema: draft.schema,
|
||||
tag: draft.tag,
|
||||
ws_error_handler_muted: draft.ws_error_handler_muted,
|
||||
priority: draft.priority,
|
||||
dedicated_worker: draft.dedicated_worker,
|
||||
timeout: draft.timeout,
|
||||
visible_to_runner_only: draft.visible_to_runner_only,
|
||||
on_behalf_of_email: draft.on_behalf_of_email,
|
||||
preserve_on_behalf_of: draft.on_behalf_of_email ? true : undefined,
|
||||
labels: (draft as { labels?: string[] }).labels,
|
||||
deployment_message: deploymentMessage
|
||||
}
|
||||
if (deployedExists) {
|
||||
// Original path in the URL, renamed path in the body — the rename move.
|
||||
await FlowService.updateFlow({ workspace, path, requestBody })
|
||||
} else {
|
||||
await FlowService.createFlow({ workspace, requestBody })
|
||||
@@ -3393,6 +3508,10 @@ async function deployDraft(
|
||||
if (loaded.source === 'deployed') {
|
||||
throw new Error(`No draft changes to deploy for app "${path}".`)
|
||||
}
|
||||
// `AppDraftValue` has no `path`; `loadDraft` surfaces the draft's stored
|
||||
// path separately so we can honour an app rename.
|
||||
deployPath = loaded.draftPath ?? path
|
||||
oldDraftOnly = loaded.draftOnly
|
||||
const appDraft = loaded.value
|
||||
const appValue: AppDraftValue = {
|
||||
...appDraft,
|
||||
@@ -3435,16 +3554,18 @@ async function deployDraft(
|
||||
data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
const summary = appValue.summary ?? ''
|
||||
// Create-vs-update keys off the deployed app at the *original* lookup path.
|
||||
if (await AppService.existsApp({ workspace, path })) {
|
||||
// Omit custom_path on update for now. The backend preserves it when absent, while
|
||||
// sending it requires admin privileges; this chat deploy path does not yet mirror
|
||||
// the raw app editor's user/admin-specific custom_path handling.
|
||||
// Original path in the URL, renamed `deployPath` in the body — the rename move.
|
||||
await AppService.updateAppRaw({
|
||||
workspace,
|
||||
path,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
path: deployPath,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
@@ -3459,7 +3580,7 @@ async function deployDraft(
|
||||
workspace,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
path: deployPath,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
@@ -3475,7 +3596,27 @@ async function deployDraft(
|
||||
}
|
||||
}
|
||||
|
||||
deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true })
|
||||
// On a rename deploy the backend deletes the draft row at the *new* target
|
||||
// path only; the stale old-lookup-path draft (and its draft_only anchor, if
|
||||
// the item never had a deployed version) would otherwise linger as a phantom.
|
||||
// Delete them explicitly. For a non-rename deploy the backend already removes
|
||||
// the draft on a non-draft create/update — adding a deleteDraft there would be
|
||||
// redundant. (capture/config moves on rename are a follow-up.)
|
||||
const renamed = (type === 'script' || type === 'flow' || type === 'app') && deployPath !== path
|
||||
if (renamed) {
|
||||
await DraftService.deleteDraft({ workspace, kind: type, path })
|
||||
if (oldDraftOnly) {
|
||||
if (type === 'script') {
|
||||
await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true })
|
||||
} else if (type === 'flow') {
|
||||
await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true })
|
||||
} else {
|
||||
await AppService.deleteApp({ workspace, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleteGlobalDraft(workspace, type, deployPath, triggerKind, { preserveLiveDraft: true })
|
||||
|
||||
// Reload the session preview if it's open on the deployed item. Map the
|
||||
// deploy type to the preview kind — a raw app deploys under 'app' but the
|
||||
@@ -3486,19 +3627,19 @@ async function deployDraft(
|
||||
app: 'raw_app'
|
||||
}
|
||||
const kind = previewKindByType[type]
|
||||
if (kind) deployedInSessionHandler?.({ sessionId, kind, path })
|
||||
if (kind) deployedInSessionHandler?.({ sessionId, kind, path: deployPath })
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Deployed ${type} "${path}"`,
|
||||
content: `Deployed ${type} "${deployPath}"`,
|
||||
result: 'Deployed',
|
||||
actions
|
||||
})
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: `Deployed draft ${type} "${path}" to the workspace. Draft removed.`,
|
||||
message: `Deployed draft ${type} "${deployPath}" to the workspace. Draft removed.`,
|
||||
type,
|
||||
path,
|
||||
path: deployPath,
|
||||
triggerKind
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -178,6 +178,13 @@ export type DbDraftRead<V> = {
|
||||
hasDbDraft: boolean
|
||||
value: V | undefined
|
||||
meta: UserDraftMeta
|
||||
/**
|
||||
* The path stored ON the resolved draft/deployed source, when it differs from
|
||||
* the lookup path (a draft rename). `AppDraftValue` has no `path` field, so the
|
||||
* app read surfaces it here for the deploy path; scripts/flows carry `path` on
|
||||
* `value` directly and leave this undefined.
|
||||
*/
|
||||
draftPath?: string
|
||||
}
|
||||
|
||||
const NOT_FOUND: DbDraftRead<never> = {
|
||||
@@ -316,13 +323,17 @@ export async function readAppDbDraft(
|
||||
// `app.draft` nests the raw app under `.value`; deployed apps expose it the
|
||||
// same way. `appSourceToDraftValue` reads `.value`/`.summary`/`.policy`/
|
||||
// `.custom_path`, falling back to the deployed app for policy/custom_path.
|
||||
const value = appSourceToDraftValue(hasDbDraft ? app.draft : app, app)
|
||||
const source = hasDbDraft ? app.draft : app
|
||||
const value = appSourceToDraftValue(source, app)
|
||||
return {
|
||||
itemExists: true,
|
||||
deployedExists: !draftOnly,
|
||||
draftOnly,
|
||||
hasDbDraft,
|
||||
value,
|
||||
meta: appDraftMeta(app)
|
||||
meta: appDraftMeta(app),
|
||||
// `AppDraftValue` drops `path`; surface the source's stored path so the
|
||||
// deploy path can honour an app draft rename.
|
||||
draftPath: (source as { path?: string }).path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Flow, NewScript, Script } from '$lib/gen/types.gen'
|
||||
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
|
||||
import type { WorkspaceItem } from './workspaceItems'
|
||||
|
||||
describe('global AI deploy request builders', () => {
|
||||
it('preserves existing script metadata while replacing draft-controlled fields', () => {
|
||||
const existing = {
|
||||
hash: 'parent-hash',
|
||||
path: 'f/demo/script',
|
||||
summary: 'existing summary',
|
||||
description: 'existing description',
|
||||
content: 'old content',
|
||||
schema: { properties: { name: { type: 'string' } } },
|
||||
is_template: true,
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
tag: 'node',
|
||||
envs: ['ENV_A'],
|
||||
concurrent_limit: 3,
|
||||
concurrency_time_window_s: 60,
|
||||
concurrency_key: 'key',
|
||||
debounce_key: 'debounce',
|
||||
debounce_delay_s: 5,
|
||||
debounce_args_to_accumulate: ['ids'],
|
||||
max_total_debouncing_time: 120,
|
||||
max_total_debounces_amount: 4,
|
||||
cache_ttl: 30,
|
||||
cache_ignore_s3_path: true,
|
||||
dedicated_worker: true,
|
||||
ws_error_handler_muted: true,
|
||||
priority: 9,
|
||||
restart_unless_cancelled: true,
|
||||
timeout: 300,
|
||||
delete_after_secs: 600,
|
||||
visible_to_runner_only: true,
|
||||
auto_kind: 'script',
|
||||
codebase: 'repo',
|
||||
has_preprocessor: true,
|
||||
on_behalf_of_email: 'deployer@example.com',
|
||||
assets: [{ path: 's3://bucket/key', kind: 's3object' }],
|
||||
modules: { 'helper.ts': { content: 'export const helper = 1', language: 'bun' } },
|
||||
labels: ['prod'],
|
||||
lock: 'stale lock'
|
||||
} as unknown as Script & Partial<NewScript>
|
||||
const draft: WorkspaceItem = {
|
||||
type: 'script',
|
||||
path: 'f/demo/script',
|
||||
summary: 'draft summary',
|
||||
language: 'bun',
|
||||
value: 'new content',
|
||||
isDraft: true
|
||||
}
|
||||
|
||||
const requestBody = buildScriptDeployRequestBody('f/demo/script', draft, existing, 'ai deploy')
|
||||
|
||||
expect(requestBody).toMatchObject({
|
||||
path: 'f/demo/script',
|
||||
parent_hash: 'parent-hash',
|
||||
summary: 'draft summary',
|
||||
description: 'existing description',
|
||||
content: 'new content',
|
||||
schema: existing.schema,
|
||||
tag: 'node',
|
||||
envs: ['ENV_A'],
|
||||
concurrent_limit: 3,
|
||||
concurrency_time_window_s: 60,
|
||||
concurrency_key: 'key',
|
||||
debounce_key: 'debounce',
|
||||
debounce_delay_s: 5,
|
||||
debounce_args_to_accumulate: ['ids'],
|
||||
max_total_debouncing_time: 120,
|
||||
max_total_debounces_amount: 4,
|
||||
cache_ttl: 30,
|
||||
cache_ignore_s3_path: true,
|
||||
dedicated_worker: true,
|
||||
ws_error_handler_muted: true,
|
||||
priority: 9,
|
||||
restart_unless_cancelled: true,
|
||||
timeout: 300,
|
||||
delete_after_secs: 600,
|
||||
visible_to_runner_only: true,
|
||||
auto_kind: 'script',
|
||||
codebase: 'repo',
|
||||
has_preprocessor: true,
|
||||
on_behalf_of_email: 'deployer@example.com',
|
||||
preserve_on_behalf_of: true,
|
||||
assets: existing.assets,
|
||||
modules: existing.modules,
|
||||
labels: ['prod'],
|
||||
deployment_message: 'ai deploy'
|
||||
})
|
||||
expect(requestBody.lock).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves existing flow metadata and uses draft value/schema overrides', () => {
|
||||
const existing = {
|
||||
path: 'f/demo/flow',
|
||||
summary: 'existing summary',
|
||||
description: 'existing description',
|
||||
value: { modules: [] },
|
||||
schema: { required: ['name'] },
|
||||
tag: 'python',
|
||||
ws_error_handler_muted: true,
|
||||
priority: 7,
|
||||
dedicated_worker: true,
|
||||
timeout: 60,
|
||||
visible_to_runner_only: true,
|
||||
on_behalf_of_email: 'deployer@example.com',
|
||||
labels: ['critical']
|
||||
} as unknown as Flow
|
||||
const draftValue = {
|
||||
value: { modules: [{ id: 'step', value: { type: 'identity' } }] },
|
||||
schema: { properties: { name: { type: 'string' } } },
|
||||
groups: [{ start_id: 'step', end_id: 'step', summary: 'Group' }]
|
||||
}
|
||||
|
||||
const requestBody = buildFlowDeployRequestBody(
|
||||
'f/demo/flow',
|
||||
undefined,
|
||||
draftValue as any,
|
||||
existing,
|
||||
'ai deploy'
|
||||
)
|
||||
|
||||
expect(requestBody).toMatchObject({
|
||||
path: 'f/demo/flow',
|
||||
summary: 'existing summary',
|
||||
description: 'existing description',
|
||||
schema: draftValue.schema,
|
||||
tag: 'python',
|
||||
ws_error_handler_muted: true,
|
||||
priority: 7,
|
||||
dedicated_worker: true,
|
||||
timeout: 60,
|
||||
visible_to_runner_only: true,
|
||||
on_behalf_of_email: 'deployer@example.com',
|
||||
preserve_on_behalf_of: true,
|
||||
labels: ['critical'],
|
||||
deployment_message: 'ai deploy'
|
||||
})
|
||||
expect(requestBody.value.modules).toHaveLength(1)
|
||||
expect(requestBody.value.groups).toEqual(draftValue.groups)
|
||||
})
|
||||
|
||||
it('falls back to existing flow schema when the draft has no schema', () => {
|
||||
const existing = {
|
||||
path: 'f/demo/flow',
|
||||
summary: 'existing summary',
|
||||
value: { modules: [] },
|
||||
schema: { properties: { existing: { type: 'boolean' } } }
|
||||
} as unknown as Flow
|
||||
|
||||
const requestBody = buildFlowDeployRequestBody(
|
||||
'f/demo/flow',
|
||||
'draft summary',
|
||||
{ value: { modules: [] }, schema: null, groups: null },
|
||||
existing,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(requestBody.summary).toBe('draft summary')
|
||||
expect(requestBody.schema).toBe(existing.schema)
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen'
|
||||
import type { FlowDraftValue, WorkspaceItem } from './workspaceItems'
|
||||
|
||||
type ScriptWithDeployMetadata = Script & Partial<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
|
||||
|
||||
export type FlowDeployRequestBody = OpenFlowWPath & {
|
||||
deployment_message?: string
|
||||
}
|
||||
|
||||
function preserveOnBehalfOf(email: string | undefined): true | undefined {
|
||||
return email ? true : undefined
|
||||
}
|
||||
|
||||
export function buildScriptDeployRequestBody(
|
||||
path: string,
|
||||
draft: WorkspaceItem,
|
||||
existing: Script | undefined,
|
||||
deploymentMessage: string | undefined
|
||||
): NewScript {
|
||||
if (typeof draft.value !== 'string' || !draft.language) {
|
||||
throw new Error(`Draft script "${path}" is missing content or language.`)
|
||||
}
|
||||
|
||||
const existingWithMetadata = existing as ScriptWithDeployMetadata | undefined
|
||||
|
||||
return {
|
||||
path,
|
||||
summary: draft.summary ?? existing?.summary ?? '',
|
||||
description: existing?.description ?? '',
|
||||
content: draft.value,
|
||||
parent_hash: existing?.hash,
|
||||
schema: existing?.schema,
|
||||
is_template: existing?.is_template,
|
||||
language: draft.language,
|
||||
kind: existing?.kind,
|
||||
tag: existing?.tag,
|
||||
envs: existing?.envs,
|
||||
concurrent_limit: existing?.concurrent_limit,
|
||||
concurrency_time_window_s: existing?.concurrency_time_window_s,
|
||||
debounce_key: existing?.debounce_key,
|
||||
debounce_delay_s: existing?.debounce_delay_s,
|
||||
debounce_args_to_accumulate: existing?.debounce_args_to_accumulate,
|
||||
max_total_debouncing_time: existing?.max_total_debouncing_time,
|
||||
max_total_debounces_amount: existing?.max_total_debounces_amount,
|
||||
cache_ttl: existing?.cache_ttl,
|
||||
cache_ignore_s3_path: existingWithMetadata?.cache_ignore_s3_path,
|
||||
dedicated_worker: existing?.dedicated_worker,
|
||||
ws_error_handler_muted: existing?.ws_error_handler_muted,
|
||||
priority: existing?.priority,
|
||||
restart_unless_cancelled: existing?.restart_unless_cancelled,
|
||||
timeout: existing?.timeout,
|
||||
delete_after_secs: existing?.delete_after_secs,
|
||||
deployment_message: deploymentMessage,
|
||||
concurrency_key: existing?.concurrency_key,
|
||||
visible_to_runner_only: existing?.visible_to_runner_only,
|
||||
auto_kind: existing?.auto_kind,
|
||||
codebase: existing?.codebase,
|
||||
has_preprocessor: existing?.has_preprocessor,
|
||||
on_behalf_of_email: existing?.on_behalf_of_email,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf(existing?.on_behalf_of_email),
|
||||
assets: existingWithMetadata?.assets,
|
||||
modules: existing?.modules,
|
||||
labels: existing?.labels
|
||||
}
|
||||
}
|
||||
|
||||
function flowValueWithDraftGroups(flowDraft: FlowDraftValue): FlowDraftValue['value'] {
|
||||
if (flowDraft.groups === undefined) {
|
||||
return flowDraft.value
|
||||
}
|
||||
return {
|
||||
...flowDraft.value,
|
||||
groups: flowDraft.groups ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFlowDeployRequestBody(
|
||||
path: string,
|
||||
draftSummary: string | undefined,
|
||||
flowDraft: FlowDraftValue,
|
||||
existing: Flow | undefined,
|
||||
deploymentMessage: string | undefined
|
||||
): FlowDeployRequestBody {
|
||||
return {
|
||||
path,
|
||||
summary: draftSummary ?? existing?.summary ?? '',
|
||||
description: existing?.description ?? '',
|
||||
value: flowValueWithDraftGroups(flowDraft),
|
||||
schema: flowDraft.schema ?? existing?.schema ?? {},
|
||||
tag: existing?.tag,
|
||||
ws_error_handler_muted: existing?.ws_error_handler_muted,
|
||||
priority: existing?.priority,
|
||||
dedicated_worker: existing?.dedicated_worker,
|
||||
timeout: existing?.timeout,
|
||||
visible_to_runner_only: existing?.visible_to_runner_only,
|
||||
on_behalf_of_email: existing?.on_behalf_of_email,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf(existing?.on_behalf_of_email),
|
||||
labels: existing?.labels,
|
||||
deployment_message: deploymentMessage
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,12 @@ export type LoadedDraft<T extends DraftType = DraftType> = {
|
||||
deployedExists: boolean
|
||||
draftOnly: boolean
|
||||
hasDbDraft: boolean
|
||||
/**
|
||||
* The path stored on the resolved source when it differs from the lookup path
|
||||
* (a draft rename). Only the app read surfaces it (its value has no `path`);
|
||||
* scripts/flows carry `path` on `value`. Undefined on the live branch.
|
||||
*/
|
||||
draftPath?: string
|
||||
}
|
||||
|
||||
function readDbDraft<T extends DraftType>(
|
||||
@@ -174,7 +180,8 @@ export async function loadDraft<T extends DraftType>(
|
||||
itemExists: db.itemExists,
|
||||
deployedExists: db.deployedExists,
|
||||
draftOnly: db.draftOnly,
|
||||
hasDbDraft: db.hasDbDraft
|
||||
hasDbDraft: db.hasDbDraft,
|
||||
draftPath: db.draftPath
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user