mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
fix(frontend): deploy full script/flow draft from AI chat via shared module (#9642)
* fix(frontend): deploy full script/flow draft from AI chat via shared module Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): drop non-persisted priority/timeout from flow draft deploy The flow branch of the shared deployDraft set `priority`/`timeout` on the create/update body, but the backend does not persist those fields on flows (a direct API write returns them as null). Remove the dead fields and the unit-test assertions for them; the flow deploy still carries every config field the backend actually stores (tag, dedicated_worker, …). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): chat deploy resolves draft storage path (honor chosen path) The chat addresses drafts by their display/chosen path, but a draft_only item created in the editor lives at a synthetic `u/{user}/draft_{uuid}` storage key (chosen path held in the draft value). The shared deployer reads the draft via getScriptByPath/getFlowByPath at the path passed, so passing the chosen path 404'd. Resolve to the storage path via getGlobalDraftStoragePath before delegating; the deployer then deploys at the draft's own `path`. Regression from the deploy-unification: the old builder read the already-resolved draft and deployed at the chosen path directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): chat raw-app deploy honors the draft's chosen path The raw-app branch deployed at the path the chat was addressed by (args.path), which for an editor-created draft_only raw app is the synthetic `u/{user}/draft_{uuid}` storage key, not the chosen path. Resolve the storage path and read the chosen path from the backend raw_app draft's `draft_path` (confirmed shape: getAppByPath{getDraft,rawApp}.draft.draft_path), then create/ update there — mirroring the script/flow storage-path resolution. Content still comes from the flat AppDraftValue, which the editor and chat both use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): flush live draft before chat deploy; narrow raw-app catch Addresses review feedback on the AI-chat deploy: - Codex P1: script/flow deploy delegates to the shared deployer, which re-reads the persisted DB draft. An open editor's edit may still be parked in a debounced/disabled autosave, so the deploy could publish a stale draft and the post-deploy draft delete could drop the unsaved edit. Flush the draft's UserDraftDbSyncer key before delegating (always saves, like Ctrl/Cmd+S, since the user explicitly asked to deploy). - Cubic P2: the raw-app draft_path lookup caught all errors and fell back to the storage path, masking real failures (network/5xx). Only fall back on 404; re-throw other errors so the deploy aborts instead of deploying to the wrong path. Adds tests for both; updates the existing raw-app deploy tests to mock getAppByPath. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): flush raw-app draft before reading draft_path on chat deploy Codex P1 follow-up: the raw-app branch derives the deploy targetPath by re-reading draft_path from the persisted backend draft, but — unlike script/flow — didn't flush first. An editor rename mirrored into draft_path can still be parked in a debounced/disabled autosave, so an immediate chat deploy could read a stale draft_path and deploy to the old path. Flush the raw_app draft key before the getAppByPath read, mirroring the script/flow fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): abort chat deploy when pre-deploy draft flush conflicts/fails Codex P1 follow-up: the pre-delegation UserDraftDbSyncer.flush() resolves even when the save recorded a conflict (server has a newer version) or failed (network/5xx) — it does not throw. The deploy would then re-read a stale or conflicting persisted draft and publish it. Add flushDraftOrThrow(): after flush, check getConflict() and getState().state === 'failed' and abort with a clear message. Used by both the script/flow and raw-app deploy paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -341,13 +341,10 @@
|
||||
let deployedAny = false
|
||||
for (const item of toDeploy) {
|
||||
deploymentStatus[item.key] = { status: 'loading' }
|
||||
const res = await deployDraft(
|
||||
item.draftKind,
|
||||
item.path,
|
||||
currentWorkspaceId,
|
||||
item.draft_only,
|
||||
item.raw_app
|
||||
)
|
||||
const res = await deployDraft(item.draftKind, item.path, currentWorkspaceId, {
|
||||
draftOnly: item.draft_only,
|
||||
rawApp: item.raw_app
|
||||
})
|
||||
if (res.success) {
|
||||
deploymentStatus[item.key] = { status: 'deployed' }
|
||||
deployedAny = true
|
||||
|
||||
@@ -605,6 +605,178 @@ describe('global AI tools', () => {
|
||||
expect(VariableService.updateVariable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deploys every field of a script draft (not just content/summary)', async () => {
|
||||
// The deploy delegates to the shared deployer, which reads the full persisted
|
||||
// draft via getScriptByPath(getDraft) and deploys all of it. Config fields
|
||||
// (tag/priority/schema/description/concurrency) were previously dropped,
|
||||
// sourced from the deployed version instead.
|
||||
seedBackendDraft(
|
||||
'script',
|
||||
'f/scripts/full',
|
||||
{ path: 'f/scripts/full', content: 'export async function main() {}', language: 'bun' },
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
hash: 1234,
|
||||
path: 'f/scripts/full',
|
||||
summary: 'Full script',
|
||||
description: 'desc',
|
||||
content: 'export async function main() {}',
|
||||
schema: { foo: 'bar' },
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
tag: 'custom-tag',
|
||||
priority: 7,
|
||||
concurrent_limit: 3,
|
||||
draft_only: true
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', { type: 'script', path: 'f/scripts/full' })
|
||||
|
||||
expect(ScriptService.createScript).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: expect.objectContaining({
|
||||
path: 'f/scripts/full',
|
||||
content: 'export async function main() {}',
|
||||
summary: 'Full script',
|
||||
description: 'desc',
|
||||
schema: { foo: 'bar' },
|
||||
language: 'bun',
|
||||
tag: 'custom-tag',
|
||||
priority: 7,
|
||||
concurrent_limit: 3,
|
||||
parent_hash: 1234
|
||||
})
|
||||
})
|
||||
// Editor-only / server-managed draft keys must not leak into the deploy body.
|
||||
const calls = vi.mocked(ScriptService.createScript).mock.calls
|
||||
const body = calls[calls.length - 1][0].requestBody as any
|
||||
expect(body.draft_only).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deploys every config field of a flow draft via createFlow', async () => {
|
||||
seedBackendDraft(
|
||||
'flow',
|
||||
'f/flows/full',
|
||||
{ summary: 'Full flow', description: 'flow desc', value: { modules: [] }, schema: {} },
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
|
||||
path: 'f/flows/full',
|
||||
summary: 'Full flow',
|
||||
description: 'flow desc',
|
||||
value: { modules: [] },
|
||||
schema: { x: 1 },
|
||||
tag: 'flow-tag',
|
||||
dedicated_worker: true
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', { type: 'flow', path: 'f/flows/full' })
|
||||
|
||||
// No deployed flow row (existsFlowByPath defaults to false) → create.
|
||||
expect(FlowService.createFlow).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: expect.objectContaining({
|
||||
path: 'f/flows/full',
|
||||
summary: 'Full flow',
|
||||
description: 'flow desc',
|
||||
value: { modules: [] },
|
||||
schema: { x: 1 },
|
||||
tag: 'flow-tag',
|
||||
dedicated_worker: true
|
||||
})
|
||||
})
|
||||
expect(FlowService.updateFlow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deploys an editor draft_only script at its chosen path, not its synthetic storage key', async () => {
|
||||
// A new script created in the editor lives at a synthetic `u/{user}/draft_{uuid}`
|
||||
// storage key while its chosen path is in the draft value. The chat addresses
|
||||
// it by the chosen (display) path; deploy must resolve to the storage key so the
|
||||
// shared deployer can read the draft via getScriptByPath, then deploy at the
|
||||
// chosen path. Reading at the chosen path would 404.
|
||||
const storageKey = 'u/admin/draft_abc123'
|
||||
const chosenPath = 'f/team/chosen_path'
|
||||
seedBackendDraft(
|
||||
'script',
|
||||
storageKey,
|
||||
{
|
||||
path: chosenPath,
|
||||
summary: 'New script',
|
||||
description: '',
|
||||
content: 'export async function main() {}',
|
||||
schema: {},
|
||||
is_template: false,
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'script',
|
||||
storagePath: storageKey,
|
||||
effectivePath: chosenPath
|
||||
})
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: chosenPath,
|
||||
summary: 'New script',
|
||||
description: '',
|
||||
content: 'export async function main() {}',
|
||||
schema: {},
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
} as any)
|
||||
|
||||
const flushSpy = vi.spyOn(UserDraftDbSyncer, 'flush')
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', { type: 'script', path: chosenPath })
|
||||
|
||||
// Any pending editor autosave is flushed at the storage key before delegating,
|
||||
// so the shared deployer reads the latest value, not a stale persisted draft.
|
||||
expect(flushSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace: WORKSPACE, itemKind: 'script', path: storageKey })
|
||||
)
|
||||
// The draft is read at the STORAGE key (the chosen path would 404)…
|
||||
expect(ScriptService.getScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace: WORKSPACE, path: storageKey, getDraft: true })
|
||||
)
|
||||
// …and deployed at the chosen path.
|
||||
expect(ScriptService.createScript).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: expect.objectContaining({ path: chosenPath })
|
||||
})
|
||||
})
|
||||
|
||||
it('aborts deploy when the pre-deploy draft flush hit a conflict', async () => {
|
||||
// flush() resolves even when the save recorded a conflict; deploy must abort
|
||||
// rather than publish the stale persisted draft.
|
||||
seedBackendDraft(
|
||||
'script',
|
||||
'f/scripts/conflicted',
|
||||
{
|
||||
path: 'f/scripts/conflicted',
|
||||
summary: '',
|
||||
description: '',
|
||||
content: 'export async function main() {}',
|
||||
schema: {},
|
||||
is_template: false,
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
const conflictSpy = vi
|
||||
.spyOn(UserDraftDbSyncer, 'getConflict')
|
||||
.mockReturnValue({ conflict: { serverTimestamp: '2026', localLastSync: null } } as any)
|
||||
|
||||
await expect(
|
||||
callGlobalTool('deploy_workspace_item', { type: 'script', path: 'f/scripts/conflicted' })
|
||||
).rejects.toThrow(/conflicting/)
|
||||
expect(ScriptService.createScript).not.toHaveBeenCalled()
|
||||
conflictSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('writes script drafts into UserDraft', async () => {
|
||||
const content = 'export async function main() {\n\treturn "hello"\n}'
|
||||
|
||||
@@ -1383,9 +1555,7 @@ describe('global AI tools', () => {
|
||||
file_path: '/min.tsx'
|
||||
})
|
||||
|
||||
expect(result).toContain(
|
||||
'lines 1-3 of 3, truncated to the first 50000 of 90002 chars.'
|
||||
)
|
||||
expect(result).toContain('lines 1-3 of 3, truncated to the first 50000 of 90002 chars.')
|
||||
expect(result).toContain('the file is likely minified')
|
||||
expect(result.split('\n\n')[1]).toHaveLength(50_000)
|
||||
})
|
||||
@@ -1401,9 +1571,7 @@ describe('global AI tools', () => {
|
||||
file_path: '/generated.js'
|
||||
})
|
||||
|
||||
expect(result).toContain(
|
||||
'lines 1-1 of 1, truncated to the first 50000 of 60000 chars.'
|
||||
)
|
||||
expect(result).toContain('lines 1-1 of 1, truncated to the first 50000 of 60000 chars.')
|
||||
expect(result).toContain('re-read with a smaller limit')
|
||||
expect(result.split('\n\n')[1]).toBe('x'.repeat(50_000))
|
||||
})
|
||||
@@ -1488,8 +1656,7 @@ describe('global AI tools', () => {
|
||||
versions: [5],
|
||||
value: {
|
||||
files: {
|
||||
'/lib/aggregations.ts':
|
||||
'export function computeRevenue(o) {\n return o.unitPrice\n}\n',
|
||||
'/lib/aggregations.ts': 'export function computeRevenue(o) {\n return o.unitPrice\n}\n',
|
||||
'/components/SummaryPanel.tsx':
|
||||
'import { computeRevenue } from "../lib/aggregations"\nconst total = computeRevenue(order)\n',
|
||||
'/components/OrdersTable.tsx': 'const r = computeRevenue(row)\n// renders revenue\n',
|
||||
@@ -1787,6 +1954,9 @@ describe('global AI tools', () => {
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
|
||||
// getAppByPath resolves with no draft_path → deploy at the item's own path.
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({} as any)
|
||||
|
||||
const raw = await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'app',
|
||||
path: 'f/apps/report',
|
||||
@@ -1832,6 +2002,93 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('deploys an editor raw app draft at its draft_path, not its synthetic storage key', async () => {
|
||||
// An editor-created draft_only raw app lives at a synthetic storage key with
|
||||
// its chosen path in `draft_path`; deploy must resolve to the storage key,
|
||||
// read draft_path, and create the app there — not at the synthetic key.
|
||||
const storageKey = 'u/admin/draft_app999'
|
||||
const chosenPath = 'f/team/chosen_app'
|
||||
seedBackendDraft(
|
||||
'raw_app',
|
||||
storageKey,
|
||||
{
|
||||
summary: 'Editor app',
|
||||
files: { '/App.tsx': 'export default () => null' },
|
||||
runnables: {},
|
||||
data: { tables: [] },
|
||||
draft_path: chosenPath
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'raw_app',
|
||||
storagePath: storageKey,
|
||||
effectivePath: chosenPath
|
||||
})
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
|
||||
draft: { draft_path: chosenPath }
|
||||
} as any)
|
||||
const flushSpy = vi.spyOn(UserDraftDbSyncer, 'flush')
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', { type: 'app', path: chosenPath })
|
||||
|
||||
// The draft is flushed at the storage key before the draft_path read, so a
|
||||
// not-yet-saved editor rename isn't read stale.
|
||||
expect(flushSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace: WORKSPACE, itemKind: 'raw_app', path: storageKey })
|
||||
)
|
||||
// draft_path is read from the backend draft at the storage key…
|
||||
expect(AppService.getAppByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspace: WORKSPACE,
|
||||
path: storageKey,
|
||||
getDraft: true,
|
||||
rawApp: true
|
||||
})
|
||||
)
|
||||
// …and the app is created at the chosen path, not the synthetic key.
|
||||
expect(AppService.createAppRaw).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: expect.objectContaining({
|
||||
app: expect.objectContaining({ path: chosenPath })
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('aborts a raw app deploy when the draft_path lookup fails (non-404)', async () => {
|
||||
// A real lookup failure (network/5xx) must abort, not silently fall back to the
|
||||
// storage path and deploy there. Only a 404 justifies the storage-path fallback.
|
||||
seedBackendDraft(
|
||||
'raw_app',
|
||||
'u/admin/draft_appfail',
|
||||
{
|
||||
summary: 'Editor app',
|
||||
files: { '/App.tsx': 'export default () => null' },
|
||||
runnables: {},
|
||||
data: { tables: [] },
|
||||
draft_path: 'f/team/chosen_app'
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'raw_app',
|
||||
storagePath: 'u/admin/draft_appfail',
|
||||
effectivePath: 'f/team/chosen_app'
|
||||
})
|
||||
vi.mocked(AppService.getAppByPath).mockRejectedValueOnce(
|
||||
Object.assign(new Error('server error'), { status: 500 })
|
||||
)
|
||||
|
||||
await expect(
|
||||
callGlobalTool('deploy_workspace_item', { type: 'app', path: 'f/team/chosen_app' })
|
||||
).rejects.toThrow()
|
||||
expect(AppService.createAppRaw).not.toHaveBeenCalled()
|
||||
expect(AppService.updateAppRaw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deploys an existing raw app draft by bundling files and updating the raw app', async () => {
|
||||
vi.mocked(AppService.existsApp).mockResolvedValueOnce(true)
|
||||
seedBackendDraft(
|
||||
@@ -1848,6 +2105,8 @@ describe('global AI tools', () => {
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({} as any)
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'app',
|
||||
path: 'f/apps/report'
|
||||
@@ -1892,6 +2151,8 @@ describe('global AI tools', () => {
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({} as any)
|
||||
|
||||
await callGlobalTool(
|
||||
'deploy_workspace_item',
|
||||
{ type: 'app', path: 'f/apps/report' },
|
||||
|
||||
@@ -99,9 +99,10 @@ import {
|
||||
type WorkspaceItem,
|
||||
type WorkspaceItemType
|
||||
} from './workspaceItems'
|
||||
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
import { deployDraft as deployDraftToWorkspace } from '$lib/utils_draft_deploy'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { bundleRawAppDraft } from './rawAppBundlerBridge'
|
||||
import {
|
||||
clearEphemeralSecretVariableDraftValue,
|
||||
@@ -2505,7 +2506,7 @@ function finishDraftWrite(
|
||||
type WriteSpec<T, A> = {
|
||||
probe: (workspace: string, path: string) => Promise<boolean>
|
||||
fetchDeployed: (workspace: string, path: string) => Promise<T>
|
||||
buildDraft: (base: T | undefined, args: A, path: string) => T
|
||||
buildDraft: (base: T | undefined, args: A, path: string) => T | Promise<T>
|
||||
beforePersist?: (workspace: string, args: A) => void
|
||||
}
|
||||
|
||||
@@ -2528,7 +2529,7 @@ async function writeDraft<T, A>(
|
||||
existed = true
|
||||
}
|
||||
|
||||
const draft = spec.buildDraft(base, args, path)
|
||||
const draft = await spec.buildDraft(base, args, path)
|
||||
spec.beforePersist?.(workspace, args)
|
||||
|
||||
const result = await persistGlobalDraft(workspace, type, path, draft, {
|
||||
@@ -2552,8 +2553,8 @@ const SCRIPT_SPEC: WriteSpec<NewScript, ScriptDraftArgs> = {
|
||||
const existing = await ScriptService.getScriptByPath({ workspace, path })
|
||||
return { ...(existing as unknown as NewScript), parent_hash: existing.hash }
|
||||
},
|
||||
buildDraft: (base, args, path) =>
|
||||
base
|
||||
buildDraft: async (base, args, path) => {
|
||||
const draft: NewScript = base
|
||||
? {
|
||||
...structuredClone(base),
|
||||
path,
|
||||
@@ -2571,6 +2572,18 @@ const SCRIPT_SPEC: WriteSpec<NewScript, ScriptDraftArgs> = {
|
||||
language: args.language,
|
||||
kind: 'script'
|
||||
}
|
||||
// Infer the arg schema from the content at save time, like the editor does,
|
||||
// so the persisted draft is the single source of truth at deploy. Keep the
|
||||
// previous schema (or empty) on failure rather than blanking it.
|
||||
try {
|
||||
const schema = emptySchema()
|
||||
await inferArgs(draft.language, draft.content, schema)
|
||||
draft.schema = schema
|
||||
} catch (e) {
|
||||
console.error('Failed to infer script schema before saving draft', e)
|
||||
}
|
||||
return draft
|
||||
}
|
||||
}
|
||||
|
||||
function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise<string> {
|
||||
@@ -3258,7 +3271,9 @@ async function searchApp(
|
||||
const header = `${totalMatchCount} match${
|
||||
totalMatchCount === 1 ? '' : 'es'
|
||||
} in ${fileCount} file${fileCount === 1 ? '' : 's'}${
|
||||
truncated ? ` (showing the first ${maxMatches}; narrow with file_glob or a more specific query)` : ''
|
||||
truncated
|
||||
? ` (showing the first ${maxMatches}; narrow with file_glob or a more specific query)`
|
||||
: ''
|
||||
}`
|
||||
|
||||
const out: string[] = [header]
|
||||
@@ -3580,6 +3595,29 @@ async function discardLocalDraft(
|
||||
)
|
||||
}
|
||||
|
||||
// Flush a draft's pending editor autosave, then verify it actually landed before
|
||||
// the caller re-reads the persisted draft. `flush()` resolves even when the save
|
||||
// recorded a conflict (server has a newer version) or failed (network/5xx) — it
|
||||
// does not throw — so without this check a deploy could publish a stale/conflicting
|
||||
// draft. Abort with a clear message instead.
|
||||
async function flushDraftOrThrow(
|
||||
query: Parameters<typeof UserDraftDbSyncer.flush>[0],
|
||||
label: string
|
||||
): Promise<void> {
|
||||
await UserDraftDbSyncer.flush(query)
|
||||
if (UserDraftDbSyncer.getConflict(query).conflict) {
|
||||
throw new Error(
|
||||
`Cannot deploy ${label}: the draft has a conflicting newer version on the server. Open it in the editor and resolve the conflict first.`
|
||||
)
|
||||
}
|
||||
const { state, failureMessage } = UserDraftDbSyncer.getState(query)
|
||||
if (state === 'failed') {
|
||||
throw new Error(
|
||||
`Cannot deploy ${label}: saving the latest draft failed (${failureMessage ?? 'unknown error'}). Retry once the draft saves.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function deployDraft(
|
||||
args: {
|
||||
type: WorkspaceItemType
|
||||
@@ -3610,169 +3648,208 @@ async function deployDraft(
|
||||
|
||||
let actions: ToolDisplayAction[] | undefined
|
||||
|
||||
switch (type) {
|
||||
case 'script': {
|
||||
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
|
||||
? await ScriptService.getScriptByPath({ workspace, path })
|
||||
: undefined
|
||||
const requestBody = buildScriptDeployRequestBody(path, draft, existing, deploymentMessage)
|
||||
// Infer the arg schema from the content so it matches the code, like the editor does.
|
||||
try {
|
||||
const schema = emptySchema()
|
||||
await inferArgs(requestBody.language, requestBody.content, schema)
|
||||
requestBody.schema = schema
|
||||
} catch (e) {
|
||||
console.error('Failed to infer script schema before deploy', e)
|
||||
}
|
||||
await ScriptService.createScript({ workspace, requestBody })
|
||||
break
|
||||
if (type === 'script' || type === 'flow') {
|
||||
// Promote the full persisted draft via the shared deploy module — the same
|
||||
// "promote a draft to deployed" code the compare page's Review & Deploy uses.
|
||||
// It deploys every field of the draft; the previous local builders dropped
|
||||
// most config fields (tag, priority, schema, description, concurrency…),
|
||||
// reading them from the already-deployed version instead. The other kinds
|
||||
// below already deploy their draft value directly, so only script/flow need
|
||||
// this. Scripts always create (with parent_hash); a flow on a deployed item
|
||||
// updates, a draft-only flow (no flow row) is created.
|
||||
// Address the draft by its STORAGE path: a draft_only item created in the
|
||||
// editor lives at a synthetic `u/{user}/draft_{uuid}` key while its chosen
|
||||
// path is held in the draft value. The shared deployer reads the draft via
|
||||
// getScriptByPath/getFlowByPath at the path we pass (then deploys at the
|
||||
// draft's own `path`), so passing the display/chosen path would 404. For a
|
||||
// draft on a deployed item the storage path is just the item path.
|
||||
const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind)
|
||||
// The shared deployer re-reads the persisted DB draft, but an open editor's
|
||||
// edit may still be parked in a debounced/disabled autosave. Flush it first so
|
||||
// we deploy the latest value (not a stale persisted one) — and so the
|
||||
// post-deploy draft delete doesn't drop an unsaved edit. `flush` always saves
|
||||
// the parked value (like Ctrl/Cmd+S), since the user explicitly asked to deploy.
|
||||
await flushDraftOrThrow({ workspace, itemKind: type, path: storagePath }, `${type} "${path}"`)
|
||||
const draftOnly =
|
||||
type === 'flow'
|
||||
? !(await FlowService.existsFlowByPath({ workspace, path: storagePath }))
|
||||
: false
|
||||
const result = await deployDraftToWorkspace(type, storagePath, workspace, {
|
||||
draftOnly,
|
||||
deploymentMessage
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? `Failed to deploy ${type} "${path}".`)
|
||||
}
|
||||
case 'flow': {
|
||||
const flowDraft = draft.value as FlowDraftValue
|
||||
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
|
||||
? await FlowService.getFlowByPath({ workspace, path })
|
||||
: undefined
|
||||
const requestBody = buildFlowDeployRequestBody(
|
||||
path,
|
||||
draft.summary,
|
||||
flowDraft,
|
||||
existing,
|
||||
deploymentMessage
|
||||
)
|
||||
if (existing) {
|
||||
await FlowService.updateFlow({ workspace, path, requestBody })
|
||||
} else {
|
||||
await FlowService.createFlow({ workspace, requestBody })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'schedule': {
|
||||
const requestBody = draft.value as any
|
||||
if (await ScheduleService.existsSchedule({ workspace, path })) {
|
||||
await ScheduleService.updateSchedule({ workspace, path, requestBody })
|
||||
} else {
|
||||
await ScheduleService.createSchedule({ workspace, requestBody })
|
||||
}
|
||||
actions = [createOpenScheduleAction(path, requestBody.is_flow ? 'flow' : 'script')]
|
||||
break
|
||||
}
|
||||
case 'trigger': {
|
||||
const service = triggerServices[triggerKind!]
|
||||
const requestBody = draft.value as { is_flow?: boolean }
|
||||
if (await service.exists({ workspace, path })) {
|
||||
await service.update({ workspace, path, requestBody })
|
||||
} else {
|
||||
await service.create({ workspace, requestBody })
|
||||
}
|
||||
actions = [
|
||||
createOpenTriggerAction(triggerKind!, path, requestBody.is_flow ? 'flow' : 'script')
|
||||
]
|
||||
break
|
||||
}
|
||||
case 'resource': {
|
||||
const requestBody = draft.value as any
|
||||
if (await ResourceService.existsResource({ workspace, path })) {
|
||||
await ResourceService.updateResource({ workspace, path, requestBody })
|
||||
} else {
|
||||
await ResourceService.createResource({ workspace, requestBody })
|
||||
}
|
||||
actions = [createOpenResourceAction(path)]
|
||||
break
|
||||
}
|
||||
case 'variable': {
|
||||
const requestBody = buildVariableDeployRequestBody(
|
||||
workspace,
|
||||
path,
|
||||
draft.value as CreateVariable
|
||||
)
|
||||
if (await VariableService.existsVariable({ workspace, path })) {
|
||||
await VariableService.updateVariable({ workspace, path, requestBody })
|
||||
} else {
|
||||
await VariableService.createVariable({ workspace, requestBody })
|
||||
}
|
||||
actions = [createOpenVariableAction(path)]
|
||||
break
|
||||
}
|
||||
case 'app': {
|
||||
const appDraft = draft.value as AppDraftValue
|
||||
const appValue: AppDraftValue = {
|
||||
...appDraft,
|
||||
files: { ...(appDraft.files ?? {}) },
|
||||
runnables: { ...(appDraft.runnables ?? {}) },
|
||||
data: appDraft.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
await recomputeAppPolicy(appValue)
|
||||
const policy = appValue.policy
|
||||
if (!policy) {
|
||||
throw new Error(`Draft app "${path}" has no policy to deploy.`)
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Bundling app "${path}"...`
|
||||
})
|
||||
const bundle = await bundleRawAppDraft({
|
||||
workspace,
|
||||
files: appValue.files,
|
||||
onLog: (delta) => {
|
||||
const lines = delta
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const latest = lines[lines.length - 1]
|
||||
if (latest) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Bundling app "${path}"... ${latest}`
|
||||
})
|
||||
}
|
||||
} else {
|
||||
switch (type) {
|
||||
case 'schedule': {
|
||||
const requestBody = draft.value as any
|
||||
if (await ScheduleService.existsSchedule({ workspace, path })) {
|
||||
await ScheduleService.updateSchedule({ workspace, path, requestBody })
|
||||
} else {
|
||||
await ScheduleService.createSchedule({ workspace, requestBody })
|
||||
}
|
||||
})
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Deploying app "${path}"...`
|
||||
})
|
||||
const rawAppValue = {
|
||||
files: appValue.files,
|
||||
runnables: appValue.runnables,
|
||||
data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
actions = [createOpenScheduleAction(path, requestBody.is_flow ? 'flow' : 'script')]
|
||||
break
|
||||
}
|
||||
const summary = appValue.summary ?? draft.summary ?? ''
|
||||
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.
|
||||
await AppService.updateAppRaw({
|
||||
case 'trigger': {
|
||||
const service = triggerServices[triggerKind!]
|
||||
const requestBody = draft.value as { is_flow?: boolean }
|
||||
if (await service.exists({ workspace, path })) {
|
||||
await service.update({ workspace, path, requestBody })
|
||||
} else {
|
||||
await service.create({ workspace, requestBody })
|
||||
}
|
||||
actions = [
|
||||
createOpenTriggerAction(triggerKind!, path, requestBody.is_flow ? 'flow' : 'script')
|
||||
]
|
||||
break
|
||||
}
|
||||
case 'resource': {
|
||||
const requestBody = draft.value as any
|
||||
if (await ResourceService.existsResource({ workspace, path })) {
|
||||
await ResourceService.updateResource({ workspace, path, requestBody })
|
||||
} else {
|
||||
await ResourceService.createResource({ workspace, requestBody })
|
||||
}
|
||||
actions = [createOpenResourceAction(path)]
|
||||
break
|
||||
}
|
||||
case 'variable': {
|
||||
// The chat keeps secret draft values only in memory (the DB draft
|
||||
// stores `''`); buildVariableDeployRequestBody re-injects the ephemeral
|
||||
// secret, so this can't go through the DB-reading shared deployer.
|
||||
const requestBody = buildVariableDeployRequestBody(
|
||||
workspace,
|
||||
path,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
deployment_message: deploymentMessage
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await AppService.createAppRaw({
|
||||
workspace,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
deployment_message: deploymentMessage,
|
||||
custom_path: appValue.custom_path
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
draft.value as CreateVariable
|
||||
)
|
||||
if (await VariableService.existsVariable({ workspace, path })) {
|
||||
await VariableService.updateVariable({ workspace, path, requestBody })
|
||||
} else {
|
||||
await VariableService.createVariable({ workspace, requestBody })
|
||||
}
|
||||
actions = [createOpenVariableAction(path)]
|
||||
break
|
||||
}
|
||||
case 'app': {
|
||||
// Raw apps store a flat AppDraftValue (files/runnables at top level),
|
||||
// not the deployed app's nested `value` shape the shared raw-app
|
||||
// deployer reads, so they deploy through the chat's own bundle path.
|
||||
const appDraft = draft.value as AppDraftValue
|
||||
const appValue: AppDraftValue = {
|
||||
...appDraft,
|
||||
files: { ...(appDraft.files ?? {}) },
|
||||
runnables: { ...(appDraft.runnables ?? {}) },
|
||||
data: appDraft.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
await recomputeAppPolicy(appValue)
|
||||
const policy = appValue.policy
|
||||
if (!policy) {
|
||||
throw new Error(`Draft app "${path}" has no policy to deploy.`)
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Bundling app "${path}"...`
|
||||
})
|
||||
const bundle = await bundleRawAppDraft({
|
||||
workspace,
|
||||
files: appValue.files,
|
||||
onLog: (delta) => {
|
||||
const lines = delta
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const latest = lines[lines.length - 1]
|
||||
if (latest) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Bundling app "${path}"... ${latest}`
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Deploying app "${path}"...`
|
||||
})
|
||||
const rawAppValue = {
|
||||
files: appValue.files,
|
||||
runnables: appValue.runnables,
|
||||
data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
const summary = appValue.summary ?? draft.summary ?? ''
|
||||
// Deploy at the draft's chosen path. A draft_only raw app created in the
|
||||
// editor lives at a synthetic `u/{user}/draft_{uuid}` storage key with its
|
||||
// chosen path in the raw_app draft's `draft_path`; the chat's AppDraftValue
|
||||
// doesn't carry it, so read it from the backend draft. For a chat-created app
|
||||
// (real path, no draft_path) or a draft on a deployed app, the storage path
|
||||
// is the deploy path. Same storage-path resolution as script/flow.
|
||||
const storagePath = getGlobalDraftStoragePath(workspace, 'app', path)
|
||||
// `draft_path` is read from the persisted backend draft below, but an
|
||||
// editor rename may still be parked in a debounced/disabled autosave.
|
||||
// Flush first (like script/flow) so we read the latest chosen path.
|
||||
await flushDraftOrThrow(
|
||||
{ workspace, itemKind: 'raw_app', path: storagePath },
|
||||
`app "${path}"`
|
||||
)
|
||||
let targetPath = storagePath
|
||||
try {
|
||||
const row = (await AppService.getAppByPath({
|
||||
workspace,
|
||||
path: storagePath,
|
||||
getDraft: true,
|
||||
rawApp: true
|
||||
})) as { draft?: { draft_path?: string; path?: string }; draft_path?: string }
|
||||
targetPath = row?.draft?.draft_path ?? row?.draft?.path ?? row?.draft_path ?? storagePath
|
||||
} catch (e) {
|
||||
// Only a missing item (404) justifies falling back to the storage path;
|
||||
// a real lookup failure (network/5xx) must abort rather than silently
|
||||
// deploy to the wrong path.
|
||||
if ((e as { status?: number } | null | undefined)?.status === 404) {
|
||||
targetPath = storagePath
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
if (await AppService.existsApp({ workspace, path: targetPath })) {
|
||||
// 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.
|
||||
await AppService.updateAppRaw({
|
||||
workspace,
|
||||
path: targetPath,
|
||||
formData: {
|
||||
app: {
|
||||
path: targetPath,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
deployment_message: deploymentMessage
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await AppService.createAppRaw({
|
||||
workspace,
|
||||
formData: {
|
||||
app: {
|
||||
path: targetPath,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
deployment_message: deploymentMessage,
|
||||
custom_path: appValue.custom_path
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,190 +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('deploys a draft-set flow description, overriding the existing one', () => {
|
||||
const existing = {
|
||||
path: 'f/demo/flow',
|
||||
summary: 'existing summary',
|
||||
description: 'existing description',
|
||||
value: { modules: [] },
|
||||
schema: {}
|
||||
} as unknown as Flow
|
||||
|
||||
const requestBody = buildFlowDeployRequestBody(
|
||||
'f/demo/flow',
|
||||
undefined,
|
||||
{
|
||||
value: { modules: [] },
|
||||
schema: null,
|
||||
groups: null,
|
||||
description: 'draft-set description'
|
||||
} as any,
|
||||
existing,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(requestBody.description).toBe('draft-set description')
|
||||
})
|
||||
|
||||
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: flowDraft.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
|
||||
}
|
||||
}
|
||||
@@ -276,9 +276,9 @@ export async function deployDraft(
|
||||
kind: DraftKind,
|
||||
path: string,
|
||||
workspace: string,
|
||||
draftOnly = false,
|
||||
rawApp = false
|
||||
opts: { draftOnly?: boolean; rawApp?: boolean; deploymentMessage?: string } = {}
|
||||
): Promise<DeployResult> {
|
||||
const { draftOnly = false, rawApp = false, deploymentMessage } = opts
|
||||
try {
|
||||
if (kind === 'raw_app' || (kind === 'app' && rawApp)) {
|
||||
// Raw apps bundle their source files and deploy via the raw-app
|
||||
@@ -286,7 +286,7 @@ export async function deployDraft(
|
||||
// `kind === 'app'` + `rawApp` (editor). Must route here: the
|
||||
// visual-app branch would `updateApp` with no `value` (RawAppDraft
|
||||
// has none) and silently drop the draft's files.
|
||||
await deployRawAppDraft(workspace, path)
|
||||
await deployRawAppDraft(workspace, path, deploymentMessage)
|
||||
} else if (kind === 'script') {
|
||||
const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any
|
||||
const d = r.draft ?? r
|
||||
@@ -297,7 +297,12 @@ export async function deployDraft(
|
||||
// the editor: createScript at the new path with parent_hash links lineage).
|
||||
await ScriptService.createScript({
|
||||
workspace,
|
||||
requestBody: { ...rest, path: scriptPath, parent_hash: r.hash }
|
||||
requestBody: {
|
||||
...rest,
|
||||
path: scriptPath,
|
||||
parent_hash: r.hash,
|
||||
deployment_message: deploymentMessage
|
||||
}
|
||||
})
|
||||
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
|
||||
await deployDraftTriggers(draftTriggers, workspace, scriptPath, true)
|
||||
@@ -319,7 +324,8 @@ export async function deployDraft(
|
||||
ws_error_handler_muted: d.ws_error_handler_muted,
|
||||
visible_to_runner_only: d.visible_to_runner_only,
|
||||
on_behalf_of_email: d.on_behalf_of_email,
|
||||
labels: d.labels
|
||||
labels: d.labels,
|
||||
deployment_message: deploymentMessage
|
||||
}
|
||||
// Draft-only flows have NO flow row (they live solely in the
|
||||
// draft table), so they deploy via createFlow; a draft on a
|
||||
@@ -360,7 +366,8 @@ export async function deployDraft(
|
||||
// Honor the draft's intended path; `draft_path` holds the user-typed path
|
||||
// for a never-deployed app parked at a `u/{user}/draft_{uuid}` storage key.
|
||||
path: draftPath ?? r.path ?? path,
|
||||
custom_path: isAdmin ? (r.custom_path ?? '') : undefined
|
||||
custom_path: isAdmin ? (r.custom_path ?? '') : undefined,
|
||||
deployment_message: deploymentMessage
|
||||
}
|
||||
// Same as flows: draft-only apps have no app row → create;
|
||||
// drafts on a deployed app update it.
|
||||
|
||||
Reference in New Issue
Block a user