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:
Guilhem
2026-06-22 10:55:15 +02:00
committed by GitHub
parent 4a8a724895
commit 23bf6bf3da
6 changed files with 526 additions and 475 deletions
@@ -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
}
}
+13 -6
View File
@@ -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.