refactor: simplify ai chat db draft deploys

This commit is contained in:
centdix
2026-06-03 12:14:21 +02:00
parent 47f1d41000
commit 458d898024
5 changed files with 995 additions and 745 deletions
@@ -166,6 +166,141 @@ const toolCallbacks: ToolCallbacks = {
removeToolStatus: vi.fn()
}
const scriptDrafts = new Map<string, any>()
const flowDrafts = new Map<string, any>()
const appDrafts = new Map<string, any>()
function serviceMock(fn: unknown): any {
return vi.mocked(fn as any) as any
}
function resetDbDraftMocks(): void {
scriptDrafts.clear()
flowDrafts.clear()
appDrafts.clear()
serviceMock(DraftService.createDraft).mockImplementation(async ({ requestBody }: any) => {
const { path, typ, value } = requestBody
if (typ === 'script') scriptDrafts.set(path, structuredClone(value))
if (typ === 'flow') flowDrafts.set(path, structuredClone(value))
if (typ === 'app') appDrafts.set(path, structuredClone(value))
return 'draft created'
})
serviceMock(DraftService.deleteDraft).mockImplementation(async ({ kind, path }: any) => {
if (kind === 'script') scriptDrafts.delete(path)
if (kind === 'flow') flowDrafts.delete(path)
if (kind === 'app') appDrafts.delete(path)
return 'draft deleted'
})
serviceMock(ScriptService.existsScriptByPath).mockImplementation(async ({ path }: any) =>
scriptDrafts.has(path)
)
serviceMock(ScriptService.getScriptByPathWithDraft).mockImplementation(async ({ path }: any) => {
const draft = scriptDrafts.get(path)
if (!draft) throw new Error('getScriptByPathWithDraft mock not configured')
return {
...draft,
hash: 'draft-anchor-hash',
draft_only: true,
draft: structuredClone(draft)
} as any
})
serviceMock(ScriptService.getScriptByPath).mockImplementation(async ({ path }: any) => {
const draft = scriptDrafts.get(path)
if (!draft) throw new Error('getScriptByPath mock not configured')
return { ...structuredClone(draft), hash: 'draft-anchor-hash', draft_only: true } as any
})
serviceMock(ScriptService.listScripts).mockImplementation(async ({ pathStart, perPage }: any) =>
Array.from(scriptDrafts.values())
.filter((draft) => !pathStart || draft.path.startsWith(pathStart))
.slice(0, perPage)
.map((draft) => ({
path: draft.path,
summary: draft.summary,
language: draft.language,
has_draft: true,
draft_only: true
}))
)
serviceMock(ScriptService.createScript).mockImplementation(async ({ requestBody }: any) => {
if (!requestBody.draft_only) scriptDrafts.delete(requestBody.path)
return 'created'
})
serviceMock(FlowService.existsFlowByPath).mockImplementation(async ({ path }: any) =>
flowDrafts.has(path)
)
serviceMock(FlowService.getFlowByPathWithDraft).mockImplementation(async ({ path }: any) => {
const draft = flowDrafts.get(path)
if (!draft) throw new Error('getFlowByPathWithDraft mock not configured')
return {
...draft,
draft_only: true,
draft: structuredClone(draft)
} as any
})
serviceMock(FlowService.getFlowByPath).mockImplementation(async ({ path }: any) => {
const draft = flowDrafts.get(path)
if (!draft) throw new Error('getFlowByPath mock not configured')
return { ...structuredClone(draft), draft_only: true } as any
})
serviceMock(FlowService.listFlows).mockImplementation(async ({ pathStart, perPage }: any) =>
Array.from(flowDrafts.values())
.filter((draft) => !pathStart || draft.path.startsWith(pathStart))
.slice(0, perPage)
.map((draft) => ({
path: draft.path,
summary: draft.summary,
has_draft: true,
draft_only: true
}))
)
serviceMock(FlowService.createFlow).mockImplementation(async ({ requestBody }: any) => {
if (!requestBody.draft_only) flowDrafts.delete(requestBody.path)
return 'created'
})
serviceMock(FlowService.updateFlow).mockImplementation(async ({ path }: any) => {
flowDrafts.delete(path)
return 'updated'
})
serviceMock(AppService.existsApp).mockImplementation(async ({ path }: any) => appDrafts.has(path))
serviceMock(AppService.getAppByPathWithDraft).mockImplementation(async ({ path }: any) => {
const draft = appDrafts.get(path)
if (!draft) throw new Error('getAppByPathWithDraft mock not configured')
const value = draft.value ?? {}
return {
path,
summary: draft.summary ?? '',
value,
policy: draft.policy,
custom_path: draft.custom_path,
draft_only: true,
draft: structuredClone(draft)
} as any
})
serviceMock(AppService.listApps).mockImplementation(async ({ pathStart, perPage }: any) =>
Array.from(appDrafts.values())
.filter((draft) => !pathStart || draft.path.startsWith(pathStart))
.slice(0, perPage)
.map((draft) => ({
path: draft.path,
summary: draft.summary,
has_draft: true,
draft_only: true
}))
)
serviceMock(AppService.createAppRaw).mockImplementation(async ({ formData }: any) => {
if (!formData.app.draft_only) appDrafts.delete(formData.app.path)
return 'created'
})
serviceMock(AppService.updateAppRaw).mockImplementation(async ({ path }: any) => {
appDrafts.delete(path)
return 'updated'
})
}
function getGlobalTool(name: string): Tool<{}> {
const tool = globalTools.find((candidate) => candidate.def.function.name === name)
if (!tool) {
@@ -215,6 +350,7 @@ describe('global AI tools', () => {
localStorage.clear()
clearGlobalDrafts(WORKSPACE)
vi.clearAllMocks()
resetDbDraftMocks()
})
it('defaults the datatable instruction subject to the TypeScript SQL SDK', async () => {
@@ -437,24 +573,23 @@ describe('global AI tools', () => {
expect(VariableService.updateVariable).not.toHaveBeenCalled()
})
it('writes script drafts into UserDraft', async () => {
it('writes script drafts into DB drafts without a non-live UserDraft mirror', async () => {
const content = 'export async function main() {\n\treturn "hello"\n}'
await callGlobalTool('write_script', {
const raw = await callGlobalTool('write_script', {
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
})
expect(UserDraft.get<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject(
{
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
}
)
expect(UserDraft.get('script', 'f/scripts/hello', { workspace: WORKSPACE })).toBeUndefined()
expect(JSON.parse(raw).item).toMatchObject({
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
value: content
})
expect(ScriptService.createScript).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: expect.objectContaining({
@@ -1107,7 +1242,7 @@ describe('global AI tools', () => {
expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined()
})
it('discards a local draft without deleting the workspace item', async () => {
it('discards a DB draft and its draft-only anchor without a local mirror', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/discard-me',
summary: 'Temporary draft',
@@ -1115,7 +1250,9 @@ describe('global AI tools', () => {
content: 'export async function main() { return 1 }'
})
expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined()
expect(
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
).toBeUndefined()
const raw = await callGlobalTool('discard_local_draft', {
type: 'script',
@@ -1128,6 +1265,16 @@ describe('global AI tools', () => {
path: 'f/scripts/discard-me'
})
expect(raw).toContain('The deployed workspace item was not changed')
expect(DraftService.deleteDraft).toHaveBeenCalledWith({
workspace: WORKSPACE,
kind: 'script',
path: 'f/scripts/discard-me'
})
expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/scripts/discard-me',
keepCaptures: true
})
expect(
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
).toBeUndefined()
@@ -1170,19 +1317,21 @@ describe('global AI tools', () => {
content: 'new content'
})
expect(
UserDraft.get<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/scripts/existing',
parent_hash: 'deployed-hash',
summary: 'new summary',
description: 'db draft description',
content: 'new content',
language: 'bun'
})
expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 'deployed-hash',
remoteDraftRev: '2026-05-22T10:00:00Z'
expect(UserDraft.get('script', 'f/scripts/existing', { workspace: WORKSPACE })).toBeUndefined()
expect(DraftService.createDraft).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/scripts/existing',
typ: 'script',
value: expect.objectContaining({
path: 'f/scripts/existing',
parent_hash: 'deployed-hash',
summary: 'new summary',
description: 'db draft description',
content: 'new content',
language: 'bun'
})
}
})
})
@@ -1219,15 +1368,19 @@ describe('global AI tools', () => {
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
})
expect(UserDraft.get<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({
path: 'f/flows/existing',
summary: 'new summary',
description: 'db draft description',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 42,
remoteDraftRev: '2026-05-22T10:00:00Z'
expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toBeUndefined()
expect(DraftService.createDraft).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/flows/existing',
typ: 'flow',
value: expect.objectContaining({
path: 'f/flows/existing',
summary: 'new summary',
description: 'db draft description',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
}
})
})
@@ -1374,26 +1527,34 @@ describe('global AI tools', () => {
content: 'export default function New() { return null }'
})
const draft = UserDraft.get<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
expect(draft).toMatchObject({
summary: 'saved app draft',
files: {
'/src/App.tsx': 'draft content',
'/src/New.tsx': 'export default function New() { return null }'
},
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' },
policy: { execution_mode: 'anonymous' },
custom_path: 'report'
})
expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({
remoteRev: 4,
remoteDraftRev: '2026-05-22T10:30:00Z'
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(DraftService.createDraft).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/apps/report',
typ: 'app',
value: expect.objectContaining({
summary: 'saved app draft',
value: expect.objectContaining({
files: {
'/src/App.tsx': 'draft content',
'/src/New.tsx': 'export default function New() { return null }'
},
runnables: {
main: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'export async function main() {}'
}
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
}),
policy: { execution_mode: 'anonymous' },
custom_path: 'report'
})
}
})
})
@@ -2098,7 +2259,7 @@ describe('global AI tools', () => {
})
})
it('test_run_step prefers local script drafts for script steps', async () => {
it('test_run_step previews DB-backed script drafts for script steps', async () => {
const content = 'export async function main(name: string) {\n\treturn `draft ${name}`\n}'
await callGlobalTool('write_script', {
path: 'f/scripts/step-script',
@@ -2141,7 +2302,7 @@ describe('global AI tools', () => {
})
})
it('test_run_step previews local draft subflows for flow steps', async () => {
it('test_run_step previews DB-backed draft subflows for flow steps', async () => {
const nestedModules = [{ id: 'nested_start', value: { type: 'identity' } }]
await callGlobalTool('write_flow', {
path: 'f/flows/nested-draft',
@@ -1,7 +1,6 @@
import {
AppService,
AzureTriggerService,
DraftService,
FlowService,
GcpTriggerService,
HttpTriggerService,
@@ -19,29 +18,24 @@ import {
} from '$lib/gen'
import { $ScriptLang } from '$lib/gen/schemas.gen'
import type {
AppWithLastVersion,
CreateResource,
CreateVariable,
Flow,
FlowValue,
ListableApp,
ListableResource,
ListableVariable,
NewSchedule,
NewScript,
Resource,
Schedule,
Script,
ScriptLang
} from '$lib/gen/types.gen'
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
import {
FRAMEWORK_TEMPLATES,
STARTER_RUNNABLE,
STARTER_RUNNABLE_KEY,
type FrameworkKey
} from '$lib/components/raw_apps/templates'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import {
applyEditableFlowJsonToFlow,
buildEditableFlowJson,
@@ -74,9 +68,8 @@ import {
} from '../shared'
import type { ContextElement } from '../context'
import { getDatatableTools } from '../datatableTools'
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { emptySchema } from '$lib/utils'
import { inferArgs } from '$lib/infer'
import {
resourceRequestSchema,
scheduleRequestSchema,
@@ -96,14 +89,27 @@ import {
type WorkspaceItemType
} from './workspaceItems'
import {
buildFlowDeployRequestBody,
buildScriptDeployRequestBody,
type FlowDeployMetadata,
type ScriptDeployMetadata
} from './deployRequests'
appToItem,
deleteDbDraftAndDraftOnlyAnchor,
deployAppDraft,
deployFlowDraft,
deployScriptDraft,
flowToItem,
isDbDraftWorkspaceItemType,
loadAppDraftValue,
loadAppValueForRead,
loadFlowWithDbDraft,
loadScriptWithDbDraft,
loadWorkspaceDraft,
loadWorkspaceDraftForDeploy,
recomputeAppPolicy,
saveAppDraft,
saveFlowDraftToDb,
saveScriptDraftToDb,
scriptToItem
} from './workspaceDrafts'
import { userStore } from '$lib/stores'
import { get } from 'svelte/store'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import {
clearEphemeralSecretVariableDraftValue,
deleteGlobalDraft,
@@ -111,8 +117,8 @@ import {
getGlobalDraft,
getGlobalDraftStoragePath,
listGlobalDrafts,
saveGlobalAppDraft,
setEphemeralSecretVariableDraftValue,
syncLiveGlobalDraft,
triggerKindToUserDraftKind
} from './userDraftAdapter'
@@ -650,57 +656,6 @@ function itemMatches(
)
}
type DraftFlagged = {
has_draft?: boolean
draft_only?: boolean
}
type ReadableScript = Pick<Script, 'path' | 'summary' | 'language' | 'content'> & DraftFlagged
function scriptToItem(
script: ReadableScript,
includeValue: boolean,
isDraft = !!script.has_draft || !!script.draft_only
): WorkspaceItem {
return {
type: 'script',
path: script.path,
summary: script.summary,
language: script.language,
value: includeValue ? script.content : undefined,
isDraft
}
}
function flowToItem(
flow: Pick<Flow, 'path' | 'summary' | 'value' | 'schema'> & DraftFlagged,
includeValue: boolean,
isDraft = !!flow.has_draft || !!flow.draft_only
): WorkspaceItem {
return {
type: 'flow',
path: flow.path,
summary: flow.summary,
value: includeValue
? { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null }
: undefined,
isDraft
}
}
async function loadScriptWithDbDraft(path: string, workspace: string): Promise<ReadableScript> {
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
return script.draft ? { ...script, ...script.draft, path: script.path } : script
}
async function loadFlowWithDbDraft(
path: string,
workspace: string
): Promise<Pick<Flow, 'path' | 'summary' | 'value' | 'schema'>> {
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
return flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow
}
/**
* Turn a flow workspace item into the compact response we send to the model:
* rawscript content is replaced with `inline_script.<moduleId>` placeholders.
@@ -896,12 +851,6 @@ type AppMetadata = {
data?: any
}
type LoadedAppDraftValue = {
value: AppDraftValue
meta?: UserDraftMeta
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
}
function summarizeAppValue(value: AppDraftValue): AppMetadata {
const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(
([path, content]) => ({
@@ -935,19 +884,6 @@ function summarizeAppValue(value: AppDraftValue): AppMetadata {
}
}
function appToItem(
app: (ListableApp | AppWithLastVersion) & DraftFlagged,
includeValue: boolean
): WorkspaceItem {
return {
type: 'app',
path: app.path,
summary: app.summary,
value: includeValue ? ((app as AppWithLastVersion).value as AppDraftValue) : undefined,
isDraft: !!app.has_draft || !!app.draft_only
}
}
const GENERATED_APP_FILE_PATHS = new Set(['/wmill.d.ts'])
function assertNotGeneratedAppFile(filePath: string): void {
@@ -1011,77 +947,6 @@ function getInlineRunnableContent(
return { content: runnable.inlineScript?.content ?? '', runnable }
}
function normalizeRawAppData(value: Record<string, any>): AppDraftValue['data'] {
if (value.data?.creation) {
return {
tables: value.data.tables ?? [],
datatable: value.data.creation.datatable,
schema: value.data.creation.schema
}
}
if (value.data) {
return value.data
}
if (value.datatables) {
return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables }
}
if (value.dataTableRefs) {
return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs }
}
return { ...DEFAULT_RAW_APP_DATA }
}
function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
const value = (app.value ?? {}) as Record<string, any>
return {
summary: app.summary ?? '',
files: { ...(value.files ?? {}) },
runnables: { ...(value.runnables ?? {}) },
data: normalizeRawAppData(value),
policy: app.policy ?? fallback?.policy,
custom_path: app.custom_path ?? fallback?.custom_path
}
}
function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta {
return {
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined,
remoteDraftRev: app.draft_created_at
}
}
async function loadAppValueForRead(path: string, workspace: string): Promise<AppDraftValue> {
const draft = getGlobalDraft(workspace, 'app', path)
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
return draft.value as AppDraftValue
}
const app = await AppService.getAppByPathWithDraft({ workspace, path })
return appSourceToDraftValue(app.draft ?? app, app)
}
async function loadAppDraftValue(path: string, workspace: string): Promise<LoadedAppDraftValue> {
const draft = getGlobalDraft(workspace, 'app', path)
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
return { value: draft.value as AppDraftValue }
}
const app = await AppService.getAppByPathWithDraft({ workspace, path })
const value = appSourceToDraftValue(app.draft ?? app, app)
return { value, meta: appDraftMeta(app), existing: app }
}
async function saveAppDraft(
workspace: string,
path: string,
value: AppDraftValue,
meta?: UserDraftMeta,
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
): Promise<WorkspaceItem> {
await saveAppDraftToDb(workspace, path, value, existing)
return saveGlobalAppDraft(workspace, path, value, meta)
}
type TriggerLike = { path: string; summary?: string | null }
function triggerToItem(
@@ -1218,12 +1083,11 @@ async function readWorkspaceItem(
)
case 'app': {
// Returns lightweight metadata only — file/runnable contents come via read_app_file.
const app = await AppService.getAppByPathWithDraft({ workspace, path })
const value = appSourceToDraftValue(app.draft ?? app)
const value = await loadAppValueForRead(path, workspace)
const metadata = summarizeAppValue(value)
return {
type: 'app',
path: app.path,
path,
summary: value.summary,
value: metadata as unknown as AppDraftValue,
isDraft: false
@@ -2169,172 +2033,6 @@ function buildVariableDeployRequestBody(
return { ...requestBody, value: secretValue }
}
type DbDraftWorkspaceItemType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
type DeployableDraft = {
item: WorkspaceItem
scriptMetadata?: ScriptDeployMetadata
flowMetadata?: FlowDeployMetadata
}
function isDbDraftWorkspaceItemType(type: WorkspaceItemType): type is DbDraftWorkspaceItemType {
return type === 'script' || type === 'flow' || type === 'app'
}
async function createDbDraft(
workspace: string,
typ: DbDraftWorkspaceItemType,
path: string,
value: unknown
): Promise<void> {
await DraftService.createDraft({
workspace,
requestBody: {
path,
typ,
value
}
})
}
async function deleteDbDraft(
workspace: string,
typ: DbDraftWorkspaceItemType,
path: string
): Promise<void> {
await DraftService.deleteDraft({ workspace, kind: typ, path })
}
async function deleteDbDraftAndDraftOnlyAnchor(
workspace: string,
typ: DbDraftWorkspaceItemType,
path: string
): Promise<void> {
switch (typ) {
case 'script': {
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
? await ScriptService.getScriptByPathWithDraft({ workspace, path })
: undefined
await deleteDbDraft(workspace, typ, path)
if (existing?.draft_only) {
await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true })
}
break
}
case 'flow': {
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
? await FlowService.getFlowByPathWithDraft({ workspace, path })
: undefined
await deleteDbDraft(workspace, typ, path)
if (existing?.draft_only) {
await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true })
}
break
}
case 'app': {
const existing = (await AppService.existsApp({ workspace, path }))
? await AppService.getAppByPathWithDraft({ workspace, path })
: undefined
await deleteDbDraft(workspace, typ, path)
if (existing?.draft_only) {
await AppService.deleteApp({ workspace, path })
}
break
}
}
}
async function loadDbDraftForDeploy(
workspace: string,
type: DbDraftWorkspaceItemType,
path: string
): Promise<DeployableDraft | undefined> {
switch (type) {
case 'script': {
if (!(await ScriptService.existsScriptByPath({ workspace, path }))) return undefined
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
if (!script.draft && !script.draft_only) return undefined
const draftScript = script.draft ? { ...script, ...script.draft, path: script.path } : script
return {
item: scriptToItem(draftScript, true, true),
scriptMetadata: draftScript
}
}
case 'flow': {
if (!(await FlowService.existsFlowByPath({ workspace, path }))) return undefined
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
if (!flow.draft && !flow.draft_only) return undefined
const draftFlow = flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow
return {
item: flowToItem(draftFlow, true, true),
flowMetadata: draftFlow
}
}
case 'app': {
if (!(await AppService.existsApp({ workspace, path }))) return undefined
const app = await AppService.getAppByPathWithDraft({ workspace, path })
if (!app.draft && !app.draft_only) return undefined
const value = appSourceToDraftValue(app.draft ?? app, app)
return {
item: {
type: 'app',
path: app.path,
summary: value.summary,
value,
isDraft: true
}
}
}
}
}
async function loadDbDraftItem(
workspace: string,
type: DbDraftWorkspaceItemType,
path: string
): Promise<WorkspaceItem | undefined> {
return (await loadDbDraftForDeploy(workspace, type, path))?.item
}
async function getGlobalOrDbDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): Promise<WorkspaceItem | undefined> {
const draft = getGlobalDraft(workspace, type, path, triggerKind)
if (draft) return draft
if (!isDbDraftWorkspaceItemType(type)) return undefined
return loadDbDraftItem(workspace, type, path)
}
async function getGlobalOrDbDraftForDeploy(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): Promise<DeployableDraft | undefined> {
const item = getGlobalDraft(workspace, type, path, triggerKind)
if (item) {
const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind)
switch (type) {
case 'script':
return {
item,
scriptMetadata: UserDraft.get<NewScript>('script', storagePath, { workspace })
}
case 'flow':
return {
item,
flowMetadata: UserDraft.get<Flow>('flow', storagePath, { workspace })
}
default:
return { item }
}
}
if (!isDbDraftWorkspaceItemType(type)) return undefined
return loadDbDraftForDeploy(workspace, type, path)
}
function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void {
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: `Saving ${type} "${path}" as a draft...`
@@ -2376,120 +2074,6 @@ function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDra
)
}
async function saveScriptDraftToDb(
workspace: string,
path: string,
draft: NewScript,
existing: Awaited<ReturnType<typeof ScriptService.getScriptByPathWithDraft>> | undefined
): Promise<void> {
if (!existing) {
await ScriptService.createScript({
workspace,
requestBody: {
...draft,
path,
parent_hash: undefined,
draft_only: true
}
})
}
await createDbDraft(workspace, 'script', path, draft)
}
async function saveFlowDraftToDb(
workspace: string,
path: string,
draft: Flow,
existing: Awaited<ReturnType<typeof FlowService.getFlowByPathWithDraft>> | undefined
): Promise<void> {
if (!existing) {
await FlowService.createFlow({
workspace,
requestBody: {
path,
summary: draft.summary ?? '',
description: (draft as any).description ?? '',
value: draft.value,
schema: draft.schema,
tag: draft.tag,
draft_only: true,
ws_error_handler_muted: draft.ws_error_handler_muted,
visible_to_runner_only: draft.visible_to_runner_only,
on_behalf_of_email: draft.on_behalf_of_email,
labels: draft.labels
}
})
}
await createDbDraft(workspace, 'flow', path, draft)
}
function appDraftToDbValue(path: string, value: AppDraftValue): Record<string, unknown> {
return {
value: {
files: value.files,
runnables: value.runnables,
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
},
summary: value.summary ?? '',
policy: value.policy,
path,
custom_path: value.custom_path
}
}
async function createRawAppDraftOnlyAnchor(
workspace: string,
path: string,
value: AppDraftValue
): Promise<void> {
await recomputeAppPolicy(value)
const policy = value.policy
if (!policy) {
throw new Error(`Draft app "${path}" has no policy to save.`)
}
const bundle = await bundleRawAppDraft({
workspace,
files: value.files,
onLog: () => {}
})
await AppService.createAppRaw({
workspace,
formData: {
app: {
path,
value: {
files: value.files,
runnables: value.runnables,
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
},
summary: value.summary ?? '',
policy,
draft_only: true,
custom_path: value.custom_path
},
js: bundle.js,
css: bundle.css
}
})
}
async function saveAppDraftToDb(
workspace: string,
path: string,
value: AppDraftValue,
existing: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>> | undefined
): Promise<void> {
const current =
existing ??
((await AppService.existsApp({ workspace, path }))
? await AppService.getAppByPathWithDraft({ workspace, path })
: undefined)
if (!current) {
await createRawAppDraftOnlyAnchor(workspace, path, value)
}
await createDbDraft(workspace, 'app', path, appDraftToDbValue(path, value))
}
async function writeScriptDraft(
args: { path: string; summary?: string; language: ScriptLang; content: string },
ctx: WriteDraftCtx
@@ -2528,23 +2112,18 @@ async function writeScriptDraft(
await saveScriptDraftToDb(workspace, args.path, draft, existing)
if (existing && !existing.draft_only) {
UserDraft.setDraftAndMeta(
'script',
storagePath,
draft,
{ remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at },
{ workspace }
)
} else {
UserDraft.save('script', storagePath, draft, { workspace })
}
return finishDraftWrite(
getRequiredGlobalDraft(workspace, 'script', args.path),
existingDraft !== undefined || existing !== undefined,
ctx
const mirrored = syncLiveGlobalDraft(
workspace,
'script',
args.path,
draft,
existing && !existing.draft_only
? { remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at }
: undefined
)
const stored = mirrored ?? scriptToItem(draft, true, true)
return finishDraftWrite(stored, existingDraft !== undefined || existing !== undefined, ctx)
}
async function writeFlowDraft(
@@ -2587,24 +2166,22 @@ async function writeFlowDraft(
await saveFlowDraftToDb(workspace, args.path, draft, existing)
if (existing && !existing.draft_only) {
const latestVersion = await FlowService.getFlowLatestVersion({ workspace, path: args.path })
UserDraft.setDraftAndMeta(
'flow',
storagePath,
draft,
{ remoteRev: latestVersion.id, remoteDraftRev: existing.draft_created_at },
{ workspace }
)
} else {
UserDraft.save('flow', storagePath, draft, { workspace })
}
return finishDraftWrite(
getRequiredGlobalDraft(workspace, 'flow', args.path),
existingDraft !== undefined || existing !== undefined,
ctx
const latestVersion =
existing && !existing.draft_only
? await FlowService.getFlowLatestVersion({ workspace, path: args.path })
: undefined
const mirrored = syncLiveGlobalDraft(
workspace,
'flow',
args.path,
draft,
latestVersion
? { remoteRev: latestVersion.id, remoteDraftRev: existing?.draft_created_at }
: undefined
)
const stored = mirrored ?? flowToItem(draft, true, true)
return finishDraftWrite(stored, existingDraft !== undefined || existing !== undefined, ctx)
}
async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promise<string> {
@@ -2903,7 +2480,8 @@ async function loadScriptForFlowStep(
moduleValue: { path: string; hash?: string },
workspace: string
): Promise<{ content: string; language: ScriptLang }> {
const draft = getGlobalDraft(workspace, 'script', moduleValue.path)
const deployableDraft = await loadWorkspaceDraftForDeploy(workspace, 'script', moduleValue.path)
const draft = deployableDraft?.item
if (draft) {
if (typeof draft.value !== 'string' || !draft.language) {
throw new Error(`Draft script "${moduleValue.path}" is missing content or language.`)
@@ -2921,11 +2499,15 @@ async function loadDraftFlowPreviewValue(
path: string,
workspace: string
): Promise<FlowValue | undefined> {
if (!getGlobalDraft(workspace, 'flow', path)) {
const draft = await loadWorkspaceDraftForDeploy(workspace, 'flow', path)
if (!draft) {
return undefined
}
const nestedFlow = await loadFlowDraftValue(path, workspace)
return flowDraftValueForPreview(nestedFlow.flow)
const flowDraft = draft.item.value
if (flowDraft === undefined || typeof flowDraft === 'string') {
throw new Error(`Draft flow "${path}" has no value.`)
}
return flowDraftValueForPreview(flowDraft as FlowDraftValue)
}
async function testRunScriptByPath(
@@ -3265,17 +2847,6 @@ async function patchAppFile(
)
}
async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
const policy = (await updateRawAppPolicy(
value.runnables as any,
value.policy as any
)) as NonNullable<AppDraftValue['policy']>
if (!policy.execution_mode) {
policy.execution_mode = 'publisher'
}
value.policy = policy
}
async function writeAppRunnable(
args: { path: string; key: string; runnable: BackendRunnableInput },
ctx: WriteDraftCtx
@@ -3412,7 +2983,7 @@ async function discardLocalDraft(
throw new Error('trigger_kind is required when discarding a trigger draft.')
}
const draft = await getGlobalOrDbDraft(workspace, type, path, triggerKind)
const draft = await loadWorkspaceDraft(workspace, type, path, triggerKind)
if (!draft) {
throw new Error(`No draft found for ${type} "${path}".`)
}
@@ -3455,7 +3026,7 @@ async function deployDraft(
throw new Error('trigger_kind is required when deploying a trigger.')
}
const deployableDraft = await getGlobalOrDbDraftForDeploy(workspace, type, path, triggerKind)
const deployableDraft = await loadWorkspaceDraftForDeploy(workspace, type, path, triggerKind)
if (!deployableDraft) {
throw new Error(`No draft found for ${type} "${path}".`)
}
@@ -3472,45 +3043,23 @@ async function deployDraft(
switch (type) {
case 'script': {
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
? await ScriptService.getScriptByPath({ workspace, path })
: undefined
const requestBody = buildScriptDeployRequestBody(
await deployScriptDraft({
workspace,
path,
draft,
existing,
deploymentMessage,
deployableDraft.scriptMetadata
)
// 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 })
draftMetadata: deployableDraft.scriptMetadata
})
break
}
case 'flow': {
const flowDraft = draft.value as FlowDraftValue
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
? await FlowService.getFlowByPath({ workspace, path })
: undefined
const requestBody = buildFlowDeployRequestBody(
await deployFlowDraft({
workspace,
path,
draft.summary,
flowDraft,
existing,
draft,
deploymentMessage,
deployableDraft.flowMetadata
)
if (existing) {
await FlowService.updateFlow({ workspace, path, requestBody })
} else {
await FlowService.createFlow({ workspace, requestBody })
}
draftMetadata: deployableDraft.flowMetadata
})
break
}
case 'schedule': {
@@ -3561,84 +3110,15 @@ async function deployDraft(
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({
await deployAppDraft({
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}`
})
}
path,
draft,
deploymentMessage,
onStatus: (content) => {
toolCallbacks.setToolStatus(toolId, { content })
}
})
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 ?? ''
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({
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
}
})
}
break
}
}
@@ -12,15 +12,61 @@ function preserveOnBehalfOf(email: string | undefined): true | undefined {
return email ? true : undefined
}
function deployMetadataField<T extends object, K extends keyof T>(
draftMetadata: T | undefined,
const SCRIPT_DEPLOY_METADATA_FIELDS = [
'schema',
'is_template',
'kind',
'tag',
'envs',
'concurrent_limit',
'concurrency_time_window_s',
'debounce_key',
'debounce_delay_s',
'debounce_args_to_accumulate',
'max_total_debouncing_time',
'max_total_debounces_amount',
'cache_ttl',
'cache_ignore_s3_path',
'dedicated_worker',
'ws_error_handler_muted',
'priority',
'restart_unless_cancelled',
'timeout',
'delete_after_secs',
'concurrency_key',
'visible_to_runner_only',
'auto_kind',
'codebase',
'has_preprocessor',
'on_behalf_of_email',
'assets',
'modules',
'labels'
] as const satisfies readonly (keyof ScriptDeployMetadata)[]
const FLOW_DEPLOY_METADATA_FIELDS = [
'tag',
'ws_error_handler_muted',
'priority',
'dedicated_worker',
'timeout',
'visible_to_runner_only',
'on_behalf_of_email',
'labels'
] as const satisfies readonly (keyof FlowDeployMetadata)[]
function mergeDeployMetadata<T extends object>(
existing: T | undefined,
key: K
): T[K] | undefined {
if (draftMetadata && Object.prototype.hasOwnProperty.call(draftMetadata, key)) {
return draftMetadata[key]
}
return existing?.[key]
draftMetadata: T | undefined
): T {
return { ...existing, ...draftMetadata } as T
}
function pickDeployMetadata<T extends object, K extends keyof T>(
source: T,
fields: readonly K[]
): Pick<T, K> {
return Object.fromEntries(fields.map((field) => [field, source[field]])) as Pick<T, K>
}
export function buildScriptDeployRequestBody(
@@ -35,87 +81,19 @@ export function buildScriptDeployRequestBody(
}
const existingWithMetadata = existing as ScriptDeployMetadata | undefined
const onBehalfOfEmail = deployMetadataField(
draftMetadata,
existingWithMetadata,
'on_behalf_of_email'
)
const metadata = mergeDeployMetadata(existingWithMetadata, draftMetadata)
const onBehalfOfEmail = metadata.on_behalf_of_email
return {
path,
summary:
draft.summary ?? deployMetadataField(draftMetadata, existingWithMetadata, 'summary') ?? '',
description: deployMetadataField(draftMetadata, existingWithMetadata, 'description') ?? '',
summary: draft.summary ?? metadata.summary ?? '',
description: metadata.description ?? '',
content: draft.value,
parent_hash: existing?.hash,
schema: deployMetadataField(draftMetadata, existingWithMetadata, 'schema'),
is_template: deployMetadataField(draftMetadata, existingWithMetadata, 'is_template'),
...pickDeployMetadata(metadata, SCRIPT_DEPLOY_METADATA_FIELDS),
language: draft.language,
kind: deployMetadataField(draftMetadata, existingWithMetadata, 'kind'),
tag: deployMetadataField(draftMetadata, existingWithMetadata, 'tag'),
envs: deployMetadataField(draftMetadata, existingWithMetadata, 'envs'),
concurrent_limit: deployMetadataField(draftMetadata, existingWithMetadata, 'concurrent_limit'),
concurrency_time_window_s: deployMetadataField(
draftMetadata,
existingWithMetadata,
'concurrency_time_window_s'
),
debounce_key: deployMetadataField(draftMetadata, existingWithMetadata, 'debounce_key'),
debounce_delay_s: deployMetadataField(draftMetadata, existingWithMetadata, 'debounce_delay_s'),
debounce_args_to_accumulate: deployMetadataField(
draftMetadata,
existingWithMetadata,
'debounce_args_to_accumulate'
),
max_total_debouncing_time: deployMetadataField(
draftMetadata,
existingWithMetadata,
'max_total_debouncing_time'
),
max_total_debounces_amount: deployMetadataField(
draftMetadata,
existingWithMetadata,
'max_total_debounces_amount'
),
cache_ttl: deployMetadataField(draftMetadata, existingWithMetadata, 'cache_ttl'),
cache_ignore_s3_path: deployMetadataField(
draftMetadata,
existingWithMetadata,
'cache_ignore_s3_path'
),
dedicated_worker: deployMetadataField(draftMetadata, existingWithMetadata, 'dedicated_worker'),
ws_error_handler_muted: deployMetadataField(
draftMetadata,
existingWithMetadata,
'ws_error_handler_muted'
),
priority: deployMetadataField(draftMetadata, existingWithMetadata, 'priority'),
restart_unless_cancelled: deployMetadataField(
draftMetadata,
existingWithMetadata,
'restart_unless_cancelled'
),
timeout: deployMetadataField(draftMetadata, existingWithMetadata, 'timeout'),
delete_after_secs: deployMetadataField(
draftMetadata,
existingWithMetadata,
'delete_after_secs'
),
deployment_message: deploymentMessage,
concurrency_key: deployMetadataField(draftMetadata, existingWithMetadata, 'concurrency_key'),
visible_to_runner_only: deployMetadataField(
draftMetadata,
existingWithMetadata,
'visible_to_runner_only'
),
auto_kind: deployMetadataField(draftMetadata, existingWithMetadata, 'auto_kind'),
codebase: deployMetadataField(draftMetadata, existingWithMetadata, 'codebase'),
has_preprocessor: deployMetadataField(draftMetadata, existingWithMetadata, 'has_preprocessor'),
on_behalf_of_email: onBehalfOfEmail,
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail),
assets: deployMetadataField(draftMetadata, existingWithMetadata, 'assets'),
modules: deployMetadataField(draftMetadata, existingWithMetadata, 'modules'),
labels: deployMetadataField(draftMetadata, existingWithMetadata, 'labels')
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail)
}
}
@@ -137,23 +115,17 @@ export function buildFlowDeployRequestBody(
deploymentMessage: string | undefined,
draftMetadata?: FlowDeployMetadata
): FlowDeployRequestBody {
const onBehalfOfEmail = deployMetadataField(draftMetadata, existing, 'on_behalf_of_email')
const metadata = mergeDeployMetadata(existing, draftMetadata)
const onBehalfOfEmail = metadata.on_behalf_of_email
return {
path,
summary: draftSummary ?? deployMetadataField(draftMetadata, existing, 'summary') ?? '',
description: deployMetadataField(draftMetadata, existing, 'description') ?? '',
summary: draftSummary ?? metadata.summary ?? '',
description: metadata.description ?? '',
value: flowValueWithDraftGroups(flowDraft),
schema: flowDraft.schema ?? deployMetadataField(draftMetadata, existing, 'schema') ?? {},
tag: deployMetadataField(draftMetadata, existing, 'tag'),
ws_error_handler_muted: deployMetadataField(draftMetadata, existing, 'ws_error_handler_muted'),
priority: deployMetadataField(draftMetadata, existing, 'priority'),
dedicated_worker: deployMetadataField(draftMetadata, existing, 'dedicated_worker'),
timeout: deployMetadataField(draftMetadata, existing, 'timeout'),
visible_to_runner_only: deployMetadataField(draftMetadata, existing, 'visible_to_runner_only'),
on_behalf_of_email: onBehalfOfEmail,
schema: flowDraft.schema ?? metadata.schema ?? {},
...pickDeployMetadata(metadata, FLOW_DEPLOY_METADATA_FIELDS),
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail),
labels: deployMetadataField(draftMetadata, existing, 'labels'),
deployment_message: deploymentMessage
}
}
@@ -364,6 +364,32 @@ export function saveGlobalAppDraft(
return stored
}
export function syncLiveGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
value: unknown,
meta?: UserDraftMeta,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (liveDraft?.storagePath !== storagePath) {
UserDraft.remove(itemKind, storagePath, { workspace })
return undefined
}
const normalized = type === 'app' ? normalizeAppDraftValue(value as AppDraftValue) : value
if (meta) {
UserDraft.setDraftAndMeta(itemKind, storagePath, normalized, meta, { workspace })
} else {
UserDraft.save(itemKind, storagePath, normalized, { workspace })
}
return getGlobalDraft(workspace, type, path, triggerKind)
}
type DeleteGlobalDraftOptions = {
preserveLiveDraft?: boolean
}
@@ -0,0 +1,611 @@
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
import { inferArgs } from '$lib/infer'
import { emptySchema } from '$lib/utils'
import type { AppWithLastVersion, Flow, ListableApp, NewScript, Script } from '$lib/gen/types.gen'
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import type {
AppDraftValue,
FlowDraftValue,
TriggerKind,
WorkspaceItem,
WorkspaceItemType
} from './workspaceItems'
import {
buildFlowDeployRequestBody,
buildScriptDeployRequestBody,
type FlowDeployMetadata,
type ScriptDeployMetadata
} from './deployRequests'
import { getGlobalDraft, getGlobalDraftStoragePath, syncLiveGlobalDraft } from './userDraftAdapter'
export type DraftFlagged = {
has_draft?: boolean
draft_only?: boolean
}
export type ReadableScript = Pick<Script, 'path' | 'summary' | 'language' | 'content'> &
DraftFlagged
export type DbDraftWorkspaceItemType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
export type DeployableDraft = {
item: WorkspaceItem
scriptMetadata?: ScriptDeployMetadata
flowMetadata?: FlowDeployMetadata
}
type DeployStatusHandler = (content: string) => void
type LoadedAppDraftValue = {
value: AppDraftValue
meta?: UserDraftMeta
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
}
export function scriptToItem(
script: ReadableScript,
includeValue: boolean,
isDraft = !!script.has_draft || !!script.draft_only
): WorkspaceItem {
return {
type: 'script',
path: script.path,
summary: script.summary,
language: script.language,
value: includeValue ? script.content : undefined,
isDraft
}
}
export function flowToItem(
flow: Pick<Flow, 'path' | 'summary' | 'value' | 'schema'> & DraftFlagged,
includeValue: boolean,
isDraft = !!flow.has_draft || !!flow.draft_only
): WorkspaceItem {
return {
type: 'flow',
path: flow.path,
summary: flow.summary,
value: includeValue
? { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null }
: undefined,
isDraft
}
}
export function appToItem(
app: (ListableApp | AppWithLastVersion) & DraftFlagged,
includeValue: boolean
): WorkspaceItem {
return {
type: 'app',
path: app.path,
summary: app.summary,
value: includeValue ? ((app as AppWithLastVersion).value as AppDraftValue) : undefined,
isDraft: !!app.has_draft || !!app.draft_only
}
}
export async function loadScriptWithDbDraft(
path: string,
workspace: string
): Promise<ReadableScript> {
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
return script.draft ? { ...script, ...script.draft, path: script.path } : script
}
export async function loadFlowWithDbDraft(
path: string,
workspace: string
): Promise<Pick<Flow, 'path' | 'summary' | 'value' | 'schema'> & DraftFlagged> {
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
return flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow
}
export function isDbDraftWorkspaceItemType(
type: WorkspaceItemType
): type is DbDraftWorkspaceItemType {
return type === 'script' || type === 'flow' || type === 'app'
}
async function createDbDraft(
workspace: string,
typ: DbDraftWorkspaceItemType,
path: string,
value: unknown
): Promise<void> {
await DraftService.createDraft({
workspace,
requestBody: {
path,
typ,
value
}
})
}
async function deleteDbDraft(
workspace: string,
typ: DbDraftWorkspaceItemType,
path: string
): Promise<void> {
await DraftService.deleteDraft({ workspace, kind: typ, path })
}
export async function deleteDbDraftAndDraftOnlyAnchor(
workspace: string,
typ: DbDraftWorkspaceItemType,
path: string
): Promise<void> {
switch (typ) {
case 'script': {
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
? await ScriptService.getScriptByPathWithDraft({ workspace, path })
: undefined
await deleteDbDraft(workspace, typ, path)
if (existing?.draft_only) {
await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true })
}
break
}
case 'flow': {
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
? await FlowService.getFlowByPathWithDraft({ workspace, path })
: undefined
await deleteDbDraft(workspace, typ, path)
if (existing?.draft_only) {
await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true })
}
break
}
case 'app': {
const existing = (await AppService.existsApp({ workspace, path }))
? await AppService.getAppByPathWithDraft({ workspace, path })
: undefined
await deleteDbDraft(workspace, typ, path)
if (existing?.draft_only) {
await AppService.deleteApp({ workspace, path })
}
break
}
}
}
export async function loadDbDraftForDeploy(
workspace: string,
type: DbDraftWorkspaceItemType,
path: string
): Promise<DeployableDraft | undefined> {
switch (type) {
case 'script': {
if (!(await ScriptService.existsScriptByPath({ workspace, path }))) return undefined
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
if (!script.draft && !script.draft_only) return undefined
const draftScript = script.draft ? { ...script, ...script.draft, path: script.path } : script
return {
item: scriptToItem(draftScript, true, true),
scriptMetadata: draftScript
}
}
case 'flow': {
if (!(await FlowService.existsFlowByPath({ workspace, path }))) return undefined
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
if (!flow.draft && !flow.draft_only) return undefined
const draftFlow = flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow
return {
item: flowToItem(draftFlow, true, true),
flowMetadata: draftFlow
}
}
case 'app': {
if (!(await AppService.existsApp({ workspace, path }))) return undefined
const app = await AppService.getAppByPathWithDraft({ workspace, path })
if (!app.draft && !app.draft_only) return undefined
const value = appSourceToDraftValue(app.draft ?? app, app)
return {
item: {
type: 'app',
path: app.path,
summary: value.summary,
value,
isDraft: true
}
}
}
}
}
export async function loadDbDraftItem(
workspace: string,
type: DbDraftWorkspaceItemType,
path: string
): Promise<WorkspaceItem | undefined> {
return (await loadDbDraftForDeploy(workspace, type, path))?.item
}
export async function loadWorkspaceDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): Promise<WorkspaceItem | undefined> {
const draft = getGlobalDraft(workspace, type, path, triggerKind)
if (draft) return draft
if (!isDbDraftWorkspaceItemType(type)) return undefined
return loadDbDraftItem(workspace, type, path)
}
export async function loadWorkspaceDraftForDeploy(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): Promise<DeployableDraft | undefined> {
const item = getGlobalDraft(workspace, type, path, triggerKind)
if (item) {
const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind)
switch (type) {
case 'script':
return {
item,
scriptMetadata: UserDraft.get<NewScript>('script', storagePath, { workspace })
}
case 'flow':
return {
item,
flowMetadata: UserDraft.get<Flow>('flow', storagePath, { workspace })
}
default:
return { item }
}
}
if (!isDbDraftWorkspaceItemType(type)) return undefined
return loadDbDraftForDeploy(workspace, type, path)
}
export async function deployScriptDraft(args: {
workspace: string
path: string
draft: WorkspaceItem
deploymentMessage?: string
draftMetadata?: ScriptDeployMetadata
}): Promise<void> {
const { workspace, path, draft, deploymentMessage, draftMetadata } = args
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
? await ScriptService.getScriptByPath({ workspace, path })
: undefined
const requestBody = buildScriptDeployRequestBody(
path,
draft,
existing,
deploymentMessage,
draftMetadata
)
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 })
}
export async function deployFlowDraft(args: {
workspace: string
path: string
draft: WorkspaceItem
deploymentMessage?: string
draftMetadata?: FlowDeployMetadata
}): Promise<void> {
const { workspace, path, draft, deploymentMessage, draftMetadata } = args
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,
draftMetadata
)
if (existing) {
await FlowService.updateFlow({ workspace, path, requestBody })
} else {
await FlowService.createFlow({ workspace, requestBody })
}
}
export async function deployAppDraft(args: {
workspace: string
path: string
draft: WorkspaceItem
deploymentMessage?: string
onStatus?: DeployStatusHandler
}): Promise<void> {
const { workspace, path, draft, deploymentMessage, onStatus } = args
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.`)
}
onStatus?.(`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) {
onStatus?.(`Bundling app "${path}"... ${latest}`)
}
}
})
onStatus?.(`Deploying app "${path}"...`)
const rawAppValue = {
files: appValue.files,
runnables: appValue.runnables,
data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA }
}
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({
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
}
})
}
}
export async function saveScriptDraftToDb(
workspace: string,
path: string,
draft: NewScript,
existing: Awaited<ReturnType<typeof ScriptService.getScriptByPathWithDraft>> | undefined
): Promise<void> {
if (!existing) {
await ScriptService.createScript({
workspace,
requestBody: {
...draft,
path,
parent_hash: undefined,
draft_only: true
}
})
}
await createDbDraft(workspace, 'script', path, draft)
}
export async function saveFlowDraftToDb(
workspace: string,
path: string,
draft: Flow,
existing: Awaited<ReturnType<typeof FlowService.getFlowByPathWithDraft>> | undefined
): Promise<void> {
if (!existing) {
await FlowService.createFlow({
workspace,
requestBody: {
path,
summary: draft.summary ?? '',
description: (draft as any).description ?? '',
value: draft.value,
schema: draft.schema,
tag: draft.tag,
draft_only: true,
ws_error_handler_muted: draft.ws_error_handler_muted,
visible_to_runner_only: draft.visible_to_runner_only,
on_behalf_of_email: draft.on_behalf_of_email,
labels: draft.labels
}
})
}
await createDbDraft(workspace, 'flow', path, draft)
}
function appDraftToDbValue(path: string, value: AppDraftValue): Record<string, unknown> {
return {
value: {
files: value.files,
runnables: value.runnables,
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
},
summary: value.summary ?? '',
policy: value.policy,
path,
custom_path: value.custom_path
}
}
async function createRawAppDraftOnlyAnchor(
workspace: string,
path: string,
value: AppDraftValue
): Promise<void> {
await recomputeAppPolicy(value)
const policy = value.policy
if (!policy) {
throw new Error(`Draft app "${path}" has no policy to save.`)
}
const bundle = await bundleRawAppDraft({
workspace,
files: value.files,
onLog: () => {}
})
await AppService.createAppRaw({
workspace,
formData: {
app: {
path,
value: {
files: value.files,
runnables: value.runnables,
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
},
summary: value.summary ?? '',
policy,
draft_only: true,
custom_path: value.custom_path
},
js: bundle.js,
css: bundle.css
}
})
}
async function saveAppDraftToDb(
workspace: string,
path: string,
value: AppDraftValue,
existing: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>> | undefined
): Promise<void> {
const current =
existing ??
((await AppService.existsApp({ workspace, path }))
? await AppService.getAppByPathWithDraft({ workspace, path })
: undefined)
if (!current) {
await createRawAppDraftOnlyAnchor(workspace, path, value)
}
await createDbDraft(workspace, 'app', path, appDraftToDbValue(path, value))
}
function normalizeRawAppData(value: Record<string, any>): AppDraftValue['data'] {
if (value.data?.creation) {
return {
tables: value.data.tables ?? [],
datatable: value.data.creation.datatable,
schema: value.data.creation.schema
}
}
if (value.data) {
return value.data
}
if (value.datatables) {
return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables }
}
if (value.dataTableRefs) {
return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs }
}
return { ...DEFAULT_RAW_APP_DATA }
}
function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
const value = (app.value ?? {}) as Record<string, any>
return {
summary: app.summary ?? '',
files: { ...(value.files ?? {}) },
runnables: { ...(value.runnables ?? {}) },
data: normalizeRawAppData(value),
policy: app.policy ?? fallback?.policy,
custom_path: app.custom_path ?? fallback?.custom_path
}
}
function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta {
return {
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined,
remoteDraftRev: app.draft_created_at
}
}
export async function loadAppValueForRead(path: string, workspace: string): Promise<AppDraftValue> {
const draft = getGlobalDraft(workspace, 'app', path)
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
return draft.value as AppDraftValue
}
const app = await AppService.getAppByPathWithDraft({ workspace, path })
return appSourceToDraftValue(app.draft ?? app, app)
}
export async function loadAppDraftValue(
path: string,
workspace: string
): Promise<LoadedAppDraftValue> {
const draft = getGlobalDraft(workspace, 'app', path)
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
return { value: draft.value as AppDraftValue }
}
const app = await AppService.getAppByPathWithDraft({ workspace, path })
const value = appSourceToDraftValue(app.draft ?? app, app)
return { value, meta: appDraftMeta(app), existing: app }
}
export async function saveAppDraft(
workspace: string,
path: string,
value: AppDraftValue,
meta?: UserDraftMeta,
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
): Promise<WorkspaceItem> {
await saveAppDraftToDb(workspace, path, value, existing)
const mirrored = syncLiveGlobalDraft(workspace, 'app', path, value, meta)
if (mirrored) return mirrored
return {
type: 'app',
path,
summary: value.summary,
value,
isDraft: true
}
}
export async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
const policy = (await updateRawAppPolicy(
value.runnables as any,
value.policy as any
)) as NonNullable<AppDraftValue['policy']>
if (!policy.execution_mode) {
policy.execution_mode = 'publisher'
}
value.policy = policy
}