mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-26 00:01:37 +00:00
feat: plug global chat drafts into userdraft (#9291)
* refactor: move global chat drafts to userdraft * feat: share script and flow drafts with editors * feat: share trigger drafts with editors * feat: share raw app drafts with editor * feat: share resource drafts with editors * docs: rename global chat drafts copy * feat: add global chat draft discard tool * fix: resolve global chat editor draft paths * fix: remove editor draft path resolver * feat: track live editor drafts in userdraft * fix: snapshot live userdraft reads * chore: checkpoint pending global draft changes * fix: address global draft review issues * fix: defer raw app draft persistence * docs: remove pr investigation docs * fix: persist live global draft writes
This commit is contained in:
@@ -100,6 +100,7 @@
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
let {
|
||||
initialPath = $bindable(''),
|
||||
@@ -123,6 +124,7 @@
|
||||
children,
|
||||
loadedFromHistoryFromUrl,
|
||||
noInitial = false,
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
onSaveInitial,
|
||||
onSaveDraft,
|
||||
onDeploy,
|
||||
@@ -588,6 +590,23 @@
|
||||
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
|
||||
const history = initHistory(untrack(() => flowStore).val)
|
||||
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
|
||||
|
||||
$effect(() => {
|
||||
if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
|
||||
const workspace = $workspaceStore
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace,
|
||||
itemKind: 'flow',
|
||||
storagePath: liveEditorDraftStoragePath,
|
||||
effectivePath: $pathStore
|
||||
})
|
||||
return () =>
|
||||
UserDraft.clearLiveEditorDraft('flow', {
|
||||
workspace,
|
||||
storagePath: liveEditorDraftStoragePath
|
||||
})
|
||||
})
|
||||
|
||||
const captureOn = writable<boolean>(false)
|
||||
const showCaptureHint = writable<boolean | undefined>(undefined)
|
||||
const flowInputEditorStateStore = writable<FlowInputEditorState>({
|
||||
|
||||
@@ -42,17 +42,75 @@ vi.mock('$lib/gen', async () => {
|
||||
|
||||
return {
|
||||
...actual,
|
||||
ScriptService: wrapService(actual.ScriptService, {
|
||||
existsScriptByPath: vi.fn(async () => false),
|
||||
createScript: vi.fn(async () => 'created'),
|
||||
getScriptByPathWithDraft: vi.fn(async () => {
|
||||
throw new Error('getScriptByPathWithDraft mock not configured')
|
||||
}),
|
||||
listScripts: vi.fn(async () => [])
|
||||
}),
|
||||
FlowService: wrapService(actual.FlowService, {
|
||||
existsFlowByPath: vi.fn(async () => false)
|
||||
existsFlowByPath: vi.fn(async () => false),
|
||||
createFlow: vi.fn(async () => 'created'),
|
||||
updateFlow: vi.fn(async () => 'updated'),
|
||||
getFlowByPath: vi.fn(async () => {
|
||||
throw new Error('getFlowByPath mock not configured')
|
||||
}),
|
||||
getFlowByPathWithDraft: vi.fn(async () => {
|
||||
throw new Error('getFlowByPathWithDraft mock not configured')
|
||||
}),
|
||||
getFlowLatestVersion: vi.fn(async () => ({ id: 1 })),
|
||||
listFlows: vi.fn(async () => [])
|
||||
}),
|
||||
ScheduleService: wrapService(actual.ScheduleService, {
|
||||
existsSchedule: vi.fn(async () => false),
|
||||
getSchedule: vi.fn(async () => {
|
||||
throw new Error('getSchedule mock not configured')
|
||||
})
|
||||
}),
|
||||
HttpTriggerService: wrapService(actual.HttpTriggerService, {
|
||||
existsHttpTrigger: vi.fn(async () => false),
|
||||
getHttpTrigger: vi.fn(async () => {
|
||||
throw new Error('getHttpTrigger mock not configured')
|
||||
})
|
||||
}),
|
||||
AppService: wrapService(actual.AppService, {
|
||||
existsApp: vi.fn(async () => false),
|
||||
getAppByPathWithDraft: vi.fn(async () => {
|
||||
throw new Error('getAppByPathWithDraft mock not configured')
|
||||
}),
|
||||
listApps: vi.fn(async () => [])
|
||||
}),
|
||||
ResourceService: wrapService(actual.ResourceService, {
|
||||
existsResource: vi.fn(async () => false),
|
||||
getResource: vi.fn(async () => {
|
||||
throw new Error('getResource mock not configured')
|
||||
})
|
||||
}),
|
||||
VariableService: wrapService(actual.VariableService, {
|
||||
existsVariable: vi.fn(async () => false)
|
||||
existsVariable: vi.fn(async () => false),
|
||||
getVariable: vi.fn(async () => {
|
||||
throw new Error('getVariable mock not configured')
|
||||
}),
|
||||
createVariable: vi.fn(async () => 'created'),
|
||||
updateVariable: vi.fn(async () => 'updated')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import { globalTools, prepareGlobalUserMessage } from './core'
|
||||
import { globalDraftStore } from './draftStore.svelte'
|
||||
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './core'
|
||||
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
|
||||
import { clearGlobalDrafts } from './userDraftAdapter'
|
||||
import {
|
||||
AppService,
|
||||
FlowService,
|
||||
HttpTriggerService,
|
||||
ResourceService,
|
||||
ScheduleService,
|
||||
ScriptService,
|
||||
VariableService
|
||||
} from '$lib/gen'
|
||||
import type { Tool, ToolCallbacks } from '../shared'
|
||||
|
||||
const WORKSPACE = 'global-core-test'
|
||||
@@ -84,9 +142,20 @@ async function callGlobalTool(
|
||||
})
|
||||
}
|
||||
|
||||
function localStorageSnapshot(): string {
|
||||
const values: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key) values.push(`${key}: ${localStorage.getItem(key)}`)
|
||||
}
|
||||
return values.join('\n')
|
||||
}
|
||||
|
||||
describe('global AI tools', () => {
|
||||
beforeEach(() => {
|
||||
globalDraftStore.clearDrafts(WORKSPACE)
|
||||
__resetUserDraftForTesting()
|
||||
localStorage.clear()
|
||||
clearGlobalDrafts(WORKSPACE)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -105,6 +174,7 @@ describe('global AI tools', () => {
|
||||
const item = JSON.parse(raw)
|
||||
|
||||
expect(raw).not.toContain('super-secret-token')
|
||||
expect(localStorageSnapshot()).not.toContain('super-secret-token')
|
||||
expect(item).toEqual({
|
||||
type: 'variable',
|
||||
path: 'f/secrets/api_key',
|
||||
@@ -113,6 +183,837 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('writes resource drafts in the editor UserDraft shape', async () => {
|
||||
vi.mocked(ResourceService.existsResource).mockResolvedValueOnce(true)
|
||||
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
|
||||
path: 'f/resources/db',
|
||||
description: 'existing database',
|
||||
value: { host: 'old.example.com', port: 5432 },
|
||||
resource_type: 'postgresql',
|
||||
labels: ['prod'],
|
||||
ws_specific: true,
|
||||
edited_at: '2026-05-22T09:30:00Z'
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_resource', {
|
||||
path: 'f/resources/db',
|
||||
value: { host: 'new.example.com', port: 5432 },
|
||||
resource_type: 'postgresql'
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
|
||||
path: 'f/resources/db',
|
||||
description: 'existing database',
|
||||
args: { host: 'new.example.com', port: 5432 },
|
||||
labels: ['prod'],
|
||||
wsSpecific: true,
|
||||
resource_type: 'postgresql'
|
||||
})
|
||||
expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: '2026-05-22T09:30:00Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes variable drafts in the editor UserDraft shape', async () => {
|
||||
vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true)
|
||||
vi.mocked(VariableService.getVariable).mockResolvedValueOnce({
|
||||
path: 'f/secrets/api_key',
|
||||
value: undefined,
|
||||
is_secret: true,
|
||||
description: 'old description',
|
||||
account: 123,
|
||||
is_oauth: true,
|
||||
expires_at: '2026-06-22T09:30:00Z',
|
||||
labels: ['prod'],
|
||||
ws_specific: true,
|
||||
edited_at: '2026-05-22T09:30:00Z'
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_variable', {
|
||||
path: 'f/secrets/api_key',
|
||||
value: 'new-secret-token',
|
||||
is_secret: true,
|
||||
description: 'new description'
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
|
||||
path: 'f/secrets/api_key',
|
||||
variable: {
|
||||
value: '',
|
||||
is_secret: true,
|
||||
description: 'new description'
|
||||
},
|
||||
labels: ['prod'],
|
||||
wsSpecific: true,
|
||||
account: 123,
|
||||
is_oauth: true,
|
||||
expires_at: '2026-06-22T09:30:00Z'
|
||||
})
|
||||
expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: '2026-05-22T09:30:00Z'
|
||||
})
|
||||
expect(localStorageSnapshot()).not.toContain('new-secret-token')
|
||||
})
|
||||
|
||||
it('deploys secret variable drafts with ephemeral values only', async () => {
|
||||
await callGlobalTool('write_variable', {
|
||||
path: 'f/secrets/api_key',
|
||||
value: 'new-secret-token',
|
||||
is_secret: true,
|
||||
description: 'new description'
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
|
||||
).toMatchObject({
|
||||
path: 'f/secrets/api_key',
|
||||
variable: {
|
||||
value: '',
|
||||
is_secret: true,
|
||||
description: 'new description'
|
||||
},
|
||||
wsSpecific: false
|
||||
})
|
||||
expect(localStorageSnapshot()).not.toContain('new-secret-token')
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'variable',
|
||||
path: 'f/secrets/api_key'
|
||||
})
|
||||
|
||||
expect(VariableService.createVariable).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: expect.objectContaining({
|
||||
path: 'f/secrets/api_key',
|
||||
value: 'new-secret-token',
|
||||
is_secret: true,
|
||||
description: 'new description',
|
||||
ws_specific: false
|
||||
})
|
||||
})
|
||||
expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(localStorageSnapshot()).not.toContain('new-secret-token')
|
||||
})
|
||||
|
||||
it('does not deploy a secret variable draft when the ephemeral value is gone', async () => {
|
||||
UserDraft.save(
|
||||
'variable',
|
||||
'f/secrets/api_key',
|
||||
{
|
||||
path: 'f/secrets/api_key',
|
||||
variable: {
|
||||
value: '',
|
||||
is_secret: true,
|
||||
description: 'new description'
|
||||
},
|
||||
labels: undefined,
|
||||
wsSpecific: false
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
|
||||
await expect(
|
||||
callGlobalTool('deploy_workspace_item', {
|
||||
type: 'variable',
|
||||
path: 'f/secrets/api_key'
|
||||
})
|
||||
).rejects.toThrow('secret draft values are kept only in memory')
|
||||
expect(VariableService.createVariable).not.toHaveBeenCalled()
|
||||
expect(VariableService.updateVariable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes script drafts into UserDraft', async () => {
|
||||
const content = 'export async function main() {\n\treturn "hello"\n}'
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('applies path_prefix to local drafts before enforcing the result limit', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/other/outside',
|
||||
summary: 'Outside draft',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return "outside" }'
|
||||
})
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/matching/inside',
|
||||
summary: 'Inside draft',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return "inside" }'
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script'],
|
||||
path_prefix: 'f/matching/',
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'f/matching/inside',
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('lists and edits the live script editor draft through its effective path', async () => {
|
||||
UserDraft.save(
|
||||
'script',
|
||||
'',
|
||||
{
|
||||
path: 'u/admin/amazed_script',
|
||||
summary: 'Live script',
|
||||
description: '',
|
||||
content: 'export async function main(a: number, b: number) {\n\treturn a + b\n}',
|
||||
schema: {},
|
||||
is_template: false,
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'script',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/admin/amazed_script'
|
||||
})
|
||||
|
||||
const listRaw = await callGlobalTool('list_workspace_items', { types: ['script'] })
|
||||
expect(JSON.parse(listRaw)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'u/admin/amazed_script',
|
||||
isDraft: true,
|
||||
isLiveDraft: true
|
||||
})
|
||||
)
|
||||
|
||||
await callGlobalTool('edit_script', {
|
||||
path: 'u/admin/amazed_script',
|
||||
old_string: 'return a + b',
|
||||
new_string: 'return a * b'
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })).toMatchObject({
|
||||
path: 'u/admin/amazed_script',
|
||||
content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}'
|
||||
})
|
||||
expect(
|
||||
UserDraft.get('script', 'u/admin/amazed_script', { workspace: WORKSPACE })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists and writes the live flow editor draft through its effective path', async () => {
|
||||
UserDraft.save(
|
||||
'flow',
|
||||
'',
|
||||
{
|
||||
path: '',
|
||||
summary: 'Live flow',
|
||||
value: { modules: [] },
|
||||
schema: {},
|
||||
edited_by: '',
|
||||
edited_at: '',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'flow',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/admin/live_flow'
|
||||
})
|
||||
|
||||
const listRaw = await callGlobalTool('list_workspace_items', { types: ['flow'] })
|
||||
expect(JSON.parse(listRaw)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'flow',
|
||||
path: 'u/admin/live_flow',
|
||||
isDraft: true,
|
||||
isLiveDraft: true
|
||||
})
|
||||
)
|
||||
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'u/admin/live_flow',
|
||||
summary: 'Updated live flow',
|
||||
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('flow', '', { workspace: WORKSPACE })).toMatchObject({
|
||||
path: 'u/admin/live_flow',
|
||||
summary: 'Updated live flow',
|
||||
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
|
||||
})
|
||||
expect(UserDraft.get('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes the live raw app editor draft through its effective path', async () => {
|
||||
UserDraft.save(
|
||||
'raw_app',
|
||||
'',
|
||||
{
|
||||
summary: 'Live app',
|
||||
files: { '/src/App.tsx': 'export default function App() { return null }' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'raw_app',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/admin/live_app'
|
||||
})
|
||||
|
||||
await callGlobalTool('write_app_file', {
|
||||
path: 'u/admin/live_app',
|
||||
file_path: '/src/New.tsx',
|
||||
content: 'export default function New() { return null }'
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('raw_app', '', { workspace: WORKSPACE })).toMatchObject({
|
||||
files: {
|
||||
'/src/App.tsx': 'export default function App() { return null }',
|
||||
'/src/New.tsx': 'export default function New() { return null }'
|
||||
}
|
||||
})
|
||||
expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('discards a local draft without deleting the workspace item', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/discard-me',
|
||||
summary: 'Temporary draft',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return 1 }'
|
||||
})
|
||||
|
||||
expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined()
|
||||
|
||||
const raw = await callGlobalTool('discard_local_draft', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/discard-me'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
success: true,
|
||||
type: 'script',
|
||||
path: 'f/scripts/discard-me'
|
||||
})
|
||||
expect(raw).toContain('The deployed workspace item was not changed')
|
||||
expect(
|
||||
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires trigger_kind when discarding a trigger draft', async () => {
|
||||
await expect(
|
||||
callGlobalTool('discard_local_draft', {
|
||||
type: 'trigger',
|
||||
path: 'f/routes/missing-kind'
|
||||
})
|
||||
).rejects.toThrow('trigger_kind is required')
|
||||
})
|
||||
|
||||
it('preserves existing script metadata and seeds freshness on first script write', async () => {
|
||||
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true)
|
||||
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
|
||||
path: 'f/scripts/existing',
|
||||
hash: 'deployed-hash',
|
||||
draft_created_at: '2026-05-22T10:00:00Z',
|
||||
summary: 'deployed summary',
|
||||
description: 'deployed description',
|
||||
content: 'old deployed content',
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
draft: {
|
||||
path: 'f/scripts/existing',
|
||||
summary: 'db draft summary',
|
||||
description: 'db draft description',
|
||||
content: 'old draft content',
|
||||
language: 'bun',
|
||||
kind: 'script'
|
||||
}
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/existing',
|
||||
summary: 'new summary',
|
||||
language: 'bun',
|
||||
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'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves existing flow metadata and seeds freshness on first flow write', async () => {
|
||||
vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true)
|
||||
vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any)
|
||||
vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({
|
||||
path: 'f/flows/existing',
|
||||
summary: 'deployed summary',
|
||||
description: 'deployed description',
|
||||
value: { modules: [] },
|
||||
schema: { properties: { deployed: { type: 'boolean' } } },
|
||||
edited_by: 'admin',
|
||||
edited_at: '2026-05-22T09:00:00Z',
|
||||
archived: false,
|
||||
extra_perms: {},
|
||||
draft_created_at: '2026-05-22T10:00:00Z',
|
||||
draft: {
|
||||
path: 'f/flows/existing',
|
||||
summary: 'db draft summary',
|
||||
description: 'db draft description',
|
||||
value: { modules: [] },
|
||||
schema: { properties: { draft: { type: 'string' } } },
|
||||
edited_by: 'admin',
|
||||
edited_at: '2026-05-22T09:30:00Z',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
}
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/existing',
|
||||
summary: 'new summary',
|
||||
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'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves editor schedule fields when writing over an existing schedule', async () => {
|
||||
vi.mocked(ScheduleService.existsSchedule).mockResolvedValueOnce(true)
|
||||
vi.mocked(ScheduleService.getSchedule).mockResolvedValueOnce({
|
||||
path: 'f/schedules/nightly',
|
||||
schedule: '0 0 0 * * *',
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
script_path: 'f/scripts/old',
|
||||
is_flow: false,
|
||||
args: {},
|
||||
extra_perms: { 'u/viewer': true },
|
||||
email: 'admin@windmill.dev',
|
||||
permissioned_as: 'u/admin',
|
||||
edited_by: 'admin',
|
||||
edited_at: '2026-05-22T09:00:00Z',
|
||||
summary: 'old summary',
|
||||
description: 'keep this description',
|
||||
no_flow_overlap: true,
|
||||
cron_version: 'v2'
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_schedule', {
|
||||
path: 'f/schedules/nightly',
|
||||
schedule: '0 15 0 * * *',
|
||||
timezone: 'Europe/Paris',
|
||||
script_path: 'f/flows/new',
|
||||
is_flow: true,
|
||||
args: { limit: 5 }
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
|
||||
).toMatchObject({
|
||||
path: 'f/schedules/nightly',
|
||||
schedule: '0 15 0 * * *',
|
||||
timezone: 'Europe/Paris',
|
||||
script_path: 'f/flows/new',
|
||||
is_flow: true,
|
||||
args: { limit: 5 },
|
||||
extra_perms: { 'u/viewer': true },
|
||||
permissioned_as: 'u/admin',
|
||||
summary: 'old summary',
|
||||
description: 'keep this description',
|
||||
no_flow_overlap: true
|
||||
})
|
||||
expect(
|
||||
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
|
||||
).not.toMatchObject({
|
||||
edited_by: expect.anything()
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves editor trigger fields when writing over an existing trigger', async () => {
|
||||
vi.mocked(HttpTriggerService.existsHttpTrigger).mockResolvedValueOnce(true)
|
||||
vi.mocked(HttpTriggerService.getHttpTrigger).mockResolvedValueOnce({
|
||||
path: 'f/routes/api',
|
||||
script_path: 'f/scripts/old',
|
||||
is_flow: false,
|
||||
route_path: 'api/old',
|
||||
http_method: 'post',
|
||||
request_type: 'sync',
|
||||
authentication_method: 'none',
|
||||
is_static_website: false,
|
||||
workspaced_route: false,
|
||||
wrap_body: false,
|
||||
raw_string: false,
|
||||
mode: 'enabled',
|
||||
extra_perms: { 'u/viewer': true },
|
||||
workspace_id: WORKSPACE,
|
||||
edited_by: 'admin',
|
||||
edited_at: '2026-05-22T09:00:00Z',
|
||||
permissioned_as: 'u/admin',
|
||||
summary: 'old route',
|
||||
description: 'keep route description'
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_trigger', {
|
||||
kind: 'http',
|
||||
config: {
|
||||
path: 'f/routes/api',
|
||||
script_path: 'f/flows/new',
|
||||
is_flow: true,
|
||||
route_path: 'api/new',
|
||||
http_method: 'get',
|
||||
authentication_method: 'windmill',
|
||||
is_static_website: false
|
||||
}
|
||||
})
|
||||
|
||||
const draft = UserDraft.get<any>('trigger_http', 'f/routes/api', { workspace: WORKSPACE })
|
||||
expect(draft).toMatchObject({
|
||||
path: 'f/routes/api',
|
||||
script_path: 'f/flows/new',
|
||||
is_flow: true,
|
||||
route_path: 'api/new',
|
||||
http_method: 'get',
|
||||
authentication_method: 'windmill',
|
||||
extra_perms: { 'u/viewer': true },
|
||||
permissioned_as: 'u/admin',
|
||||
summary: 'old route',
|
||||
description: 'keep route description'
|
||||
})
|
||||
expect(draft).not.toMatchObject({
|
||||
workspace_id: expect.anything(),
|
||||
edited_by: expect.anything()
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds raw app draft metadata on first app write', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [3, 4],
|
||||
draft_created_at: '2026-05-22T10:30:00Z',
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'deployed content' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
},
|
||||
policy: { execution_mode: 'publisher' },
|
||||
custom_path: 'report',
|
||||
draft: {
|
||||
summary: 'saved app draft',
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'draft content' },
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'bun', content: 'export async function main() {}' }
|
||||
}
|
||||
},
|
||||
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
|
||||
},
|
||||
policy: { execution_mode: 'anonymous' }
|
||||
}
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_app_file', {
|
||||
path: 'f/apps/report',
|
||||
file_path: '/src/New.tsx',
|
||||
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'
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes local raw app drafts in read_workspace_item', async () => {
|
||||
UserDraft.save(
|
||||
'raw_app',
|
||||
'f/apps/local',
|
||||
{
|
||||
summary: 'local app',
|
||||
files: { '/src/App.tsx': 'const frontendSecret = "do-not-dump"' },
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: {
|
||||
language: 'bun',
|
||||
content: 'const backendSecret = "do-not-dump"'
|
||||
}
|
||||
}
|
||||
},
|
||||
data: { tables: ['orders'] }
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'app',
|
||||
path: 'f/apps/local'
|
||||
})
|
||||
const item = JSON.parse(raw)
|
||||
|
||||
expect(raw).not.toContain('frontendSecret')
|
||||
expect(raw).not.toContain('backendSecret')
|
||||
expect(item).toMatchObject({
|
||||
type: 'app',
|
||||
path: 'f/apps/local',
|
||||
summary: 'local app',
|
||||
isDraft: true,
|
||||
value: {
|
||||
frontend: [{ path: '/src/App.tsx', size: 'const frontendSecret = "do-not-dump"'.length }],
|
||||
backend: [
|
||||
expect.objectContaining({
|
||||
key: 'main',
|
||||
name: 'main',
|
||||
type: 'inline',
|
||||
language: 'bun',
|
||||
contentSize: 'const backendSecret = "do-not-dump"'.length
|
||||
})
|
||||
],
|
||||
data: { tables: ['orders'] }
|
||||
}
|
||||
})
|
||||
expect(item.value.backend[0]).not.toHaveProperty('content')
|
||||
})
|
||||
|
||||
it('summarizes backend raw app drafts from the same source as file reads', async () => {
|
||||
const appWithDraft = {
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'deployed content' },
|
||||
runnables: {},
|
||||
data: { tables: ['deployed'] }
|
||||
},
|
||||
draft: {
|
||||
summary: 'saved app draft',
|
||||
value: {
|
||||
files: {
|
||||
'/src/App.tsx': 'draft content',
|
||||
'/src/DraftOnly.tsx': 'draft-only content'
|
||||
},
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: {
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return "draft" }'
|
||||
}
|
||||
}
|
||||
},
|
||||
data: { tables: ['draft'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
vi.mocked(AppService.getAppByPathWithDraft)
|
||||
.mockResolvedValueOnce(appWithDraft as any)
|
||||
.mockResolvedValueOnce(appWithDraft as any)
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'app',
|
||||
path: 'f/apps/report'
|
||||
})
|
||||
const item = JSON.parse(raw)
|
||||
|
||||
expect(raw).not.toContain('draft-only content')
|
||||
expect(item).toMatchObject({
|
||||
type: 'app',
|
||||
path: 'f/apps/report',
|
||||
summary: 'saved app draft',
|
||||
value: {
|
||||
frontend: [
|
||||
{ path: '/src/App.tsx', size: 'draft content'.length },
|
||||
{ path: '/src/DraftOnly.tsx', size: 'draft-only content'.length }
|
||||
],
|
||||
backend: [
|
||||
expect.objectContaining({
|
||||
key: 'main',
|
||||
name: 'main',
|
||||
type: 'inline',
|
||||
language: 'bun',
|
||||
contentSize: 'export async function main() { return "draft" }'.length
|
||||
})
|
||||
],
|
||||
data: { tables: ['draft'] }
|
||||
},
|
||||
isDraft: false
|
||||
})
|
||||
|
||||
await expect(
|
||||
callGlobalTool('read_app_file', {
|
||||
path: 'f/apps/report',
|
||||
file_path: '/src/DraftOnly.tsx'
|
||||
})
|
||||
).resolves.toBe('draft-only content')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads raw app files without creating a local draft', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'deployed content' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
},
|
||||
draft: {
|
||||
summary: 'saved app draft',
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'draft content' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
}
|
||||
}
|
||||
} as any)
|
||||
|
||||
await expect(
|
||||
callGlobalTool('read_app_file', {
|
||||
path: 'f/apps/report',
|
||||
file_path: '/src/App.tsx'
|
||||
})
|
||||
).resolves.toBe('draft content')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not persist a raw app draft when patch_app_file validation fails', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'deployed content' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
}
|
||||
} as any)
|
||||
|
||||
await expect(
|
||||
callGlobalTool('patch_app_file', {
|
||||
path: 'f/apps/report',
|
||||
file_path: '/src/App.tsx',
|
||||
old_string: 'missing content',
|
||||
new_string: 'replacement',
|
||||
replace_all: false
|
||||
})
|
||||
).rejects.toThrow()
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not persist a raw app draft when delete_app_file validation fails', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'deployed content' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
}
|
||||
} as any)
|
||||
|
||||
await expect(
|
||||
callGlobalTool('delete_app_file', {
|
||||
path: 'f/apps/report',
|
||||
file_path: '/src/Missing.tsx'
|
||||
})
|
||||
).rejects.toThrow('Frontend file "/src/Missing.tsx" not found in app "f/apps/report".')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not persist a raw app draft when delete_app_runnable validation fails', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
value: {
|
||||
files: { '/src/App.tsx': 'deployed content' },
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'bun', content: 'export async function main() {}' }
|
||||
}
|
||||
},
|
||||
data: { tables: [] }
|
||||
}
|
||||
} as any)
|
||||
|
||||
await expect(
|
||||
callGlobalTool('delete_app_runnable', {
|
||||
path: 'f/apps/report',
|
||||
key: 'missing'
|
||||
})
|
||||
).rejects.toThrow('Backend runnable "missing" not found in app "f/apps/report".')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fills an empty rawscript module through set_flow_module_code', async () => {
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/empty-module',
|
||||
@@ -138,7 +1039,7 @@ describe('global AI tools', () => {
|
||||
module_id: 'empty_step',
|
||||
code
|
||||
})
|
||||
).resolves.toContain('Updated AI draft flow')
|
||||
).resolves.toContain('Updated local draft flow')
|
||||
|
||||
await expect(
|
||||
callGlobalTool('read_flow_module_code', {
|
||||
@@ -239,6 +1140,36 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareGlobalSystemMessage', () => {
|
||||
it('keeps global chat draft instructions concise and user-facing', () => {
|
||||
const message = prepareGlobalSystemMessage()
|
||||
const content = message.content
|
||||
|
||||
expect(content).toContain('Draft tools create or update local drafts only')
|
||||
expect(content).toContain(
|
||||
'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft'
|
||||
)
|
||||
expect(content).not.toContain('AI draft')
|
||||
expect(content).not.toContain('UserDraft')
|
||||
expect(content).not.toContain('localStorage')
|
||||
expect(content).not.toContain('frontend AI draft store')
|
||||
})
|
||||
|
||||
it('exposes separate tools for discarding drafts and deleting workspace items', () => {
|
||||
const discard = getGlobalTool('discard_local_draft')
|
||||
const deleteItem = getGlobalTool('delete_workspace_item')
|
||||
|
||||
expect(discard.def.function.description).toBe(
|
||||
'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.'
|
||||
)
|
||||
expect(deleteItem.def.function.description).toBe(
|
||||
'Delete a deployed workspace item. Mutates the workspace.'
|
||||
)
|
||||
expect(discard.requiresConfirmation).toBe(true)
|
||||
expect(deleteItem.requiresConfirmation).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareGlobalUserMessage', () => {
|
||||
it('includes selected workspace item references without contents', () => {
|
||||
const message = prepareGlobalUserMessage('Update these items', [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
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 './draftStore.svelte'
|
||||
import type { WorkspaceItem } from './workspaceItems'
|
||||
|
||||
describe('global AI deploy request builders', () => {
|
||||
it('preserves existing script metadata while replacing draft-controlled fields', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen'
|
||||
import type { FlowDraftValue, WorkspaceItem } from './draftStore.svelte'
|
||||
import type { FlowDraftValue, WorkspaceItem } from './workspaceItems'
|
||||
|
||||
type ScriptWithDeployMetadata = Script & Partial<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import type {
|
||||
AzureTriggerData,
|
||||
CreateResource,
|
||||
CreateVariable,
|
||||
FlowValue,
|
||||
GcpTriggerData,
|
||||
NewHttpTrigger,
|
||||
NewKafkaTrigger,
|
||||
NewMqttTrigger,
|
||||
NewNatsTrigger,
|
||||
NewPostgresTrigger,
|
||||
NewSchedule,
|
||||
NewSqsTrigger,
|
||||
NewWebsocketTrigger,
|
||||
Policy,
|
||||
ScriptLang
|
||||
} from '$lib/gen/types.gen'
|
||||
|
||||
/**
|
||||
* Flow draft value. Mirrors what the backend's create/update flow API expects
|
||||
* — the OpenFlow value, plus the inputs schema and (optional) groups.
|
||||
*
|
||||
* Schema and groups are split out from FlowValue intentionally so that
|
||||
* deploy_workspace_item can preserve them through the draft → workspace
|
||||
* round-trip; an earlier version dropped them on every deploy.
|
||||
*/
|
||||
export type FlowDraftValue = {
|
||||
value: FlowValue
|
||||
schema?: Record<string, any> | null
|
||||
groups?: NonNullable<FlowValue['groups']> | null
|
||||
}
|
||||
|
||||
export const TRIGGER_KINDS = [
|
||||
'http',
|
||||
'websocket',
|
||||
'kafka',
|
||||
'nats',
|
||||
'postgres',
|
||||
'mqtt',
|
||||
'sqs',
|
||||
'gcp',
|
||||
'azure'
|
||||
] as const
|
||||
|
||||
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
|
||||
|
||||
export type TriggerRequestBody =
|
||||
| NewHttpTrigger
|
||||
| NewWebsocketTrigger
|
||||
| NewKafkaTrigger
|
||||
| NewNatsTrigger
|
||||
| NewPostgresTrigger
|
||||
| NewMqttTrigger
|
||||
| NewSqsTrigger
|
||||
| GcpTriggerData
|
||||
| AzureTriggerData
|
||||
|
||||
export type WorkspaceItemType =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'schedule'
|
||||
| 'trigger'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'app'
|
||||
|
||||
export type AppDraftValue = {
|
||||
summary?: string
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, any>
|
||||
data?: any
|
||||
policy?: Policy
|
||||
custom_path?: string
|
||||
}
|
||||
|
||||
export type WorkspaceItem = {
|
||||
type: WorkspaceItemType
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
triggerKind?: TriggerKind
|
||||
value?:
|
||||
| string
|
||||
| FlowDraftValue
|
||||
| NewSchedule
|
||||
| TriggerRequestBody
|
||||
| CreateResource
|
||||
| CreateVariable
|
||||
| AppDraftValue
|
||||
isDraft: boolean
|
||||
}
|
||||
|
||||
export function getWorkspaceItemKey(
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): string {
|
||||
if (type === 'trigger') {
|
||||
return `trigger:${triggerKind ?? ''}:${path}`
|
||||
}
|
||||
return `${type}:${path}`
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone($state.snapshot(value)) as T
|
||||
}
|
||||
|
||||
class GlobalDraftStore {
|
||||
private drafts = $state<Record<string, Record<string, WorkspaceItem>>>({})
|
||||
|
||||
private getWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
|
||||
return this.drafts[workspace] ?? {}
|
||||
}
|
||||
|
||||
private ensureWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
|
||||
if (!this.drafts[workspace]) {
|
||||
this.drafts[workspace] = {}
|
||||
}
|
||||
return this.drafts[workspace]
|
||||
}
|
||||
|
||||
listDrafts(workspace: string): WorkspaceItem[] {
|
||||
return Object.values(this.getWorkspaceDrafts(workspace)).map(clone)
|
||||
}
|
||||
|
||||
getDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
const draft = this.getWorkspaceDrafts(workspace)[getWorkspaceItemKey(type, path, triggerKind)]
|
||||
return draft ? clone(draft) : undefined
|
||||
}
|
||||
|
||||
setDraft(workspace: string, item: WorkspaceItem): WorkspaceItem {
|
||||
const stored: WorkspaceItem = { ...clone(item), isDraft: true }
|
||||
this.ensureWorkspaceDrafts(workspace)[
|
||||
getWorkspaceItemKey(item.type, item.path, item.triggerKind)
|
||||
] = stored
|
||||
return clone(stored)
|
||||
}
|
||||
|
||||
deleteDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): void {
|
||||
const drafts = this.drafts[workspace]
|
||||
if (!drafts) return
|
||||
|
||||
delete drafts[getWorkspaceItemKey(type, path, triggerKind)]
|
||||
if (Object.keys(drafts).length === 0) {
|
||||
delete this.drafts[workspace]
|
||||
}
|
||||
}
|
||||
|
||||
clearDrafts(workspace: string): void {
|
||||
delete this.drafts[workspace]
|
||||
}
|
||||
|
||||
getScriptDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'script', path)
|
||||
}
|
||||
|
||||
getFlowDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'flow', path)
|
||||
}
|
||||
|
||||
getScheduleDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'schedule', path)
|
||||
}
|
||||
|
||||
getTriggerDraft(workspace: string, kind: TriggerKind, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'trigger', path, kind)
|
||||
}
|
||||
|
||||
getResourceDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'resource', path)
|
||||
}
|
||||
|
||||
getVariableDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'variable', path)
|
||||
}
|
||||
|
||||
getAppDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'app', path)
|
||||
}
|
||||
}
|
||||
|
||||
export const globalDraftStore = new GlobalDraftStore()
|
||||
@@ -1,56 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { globalDraftStore } from './draftStore.svelte'
|
||||
|
||||
const WORKSPACE_A = 'draft-store-test-a'
|
||||
const WORKSPACE_B = 'draft-store-test-b'
|
||||
|
||||
function clearTestDrafts() {
|
||||
globalDraftStore.clearDrafts(WORKSPACE_A)
|
||||
globalDraftStore.clearDrafts(WORKSPACE_B)
|
||||
}
|
||||
|
||||
describe('globalDraftStore', () => {
|
||||
beforeEach(clearTestDrafts)
|
||||
|
||||
it('lists and reads drafts only from the requested workspace', () => {
|
||||
globalDraftStore.setDraft(WORKSPACE_A, {
|
||||
type: 'script',
|
||||
path: 'f/shared/path',
|
||||
language: 'bun',
|
||||
value: 'export async function main() {}',
|
||||
isDraft: true
|
||||
})
|
||||
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_A, 'script', 'f/shared/path')?.value).toBe(
|
||||
'export async function main() {}'
|
||||
)
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_B, 'script', 'f/shared/path')).toBeUndefined()
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(1)
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deletes and clears drafts only from the requested workspace', () => {
|
||||
globalDraftStore.setDraft(WORKSPACE_A, {
|
||||
type: 'flow',
|
||||
path: 'f/shared/path',
|
||||
value: { value: { modules: [] }, schema: null, groups: null },
|
||||
isDraft: true
|
||||
})
|
||||
globalDraftStore.setDraft(WORKSPACE_B, {
|
||||
type: 'flow',
|
||||
path: 'f/shared/path',
|
||||
value: { value: { modules: [] }, schema: { workspace: WORKSPACE_B }, groups: null },
|
||||
isDraft: true
|
||||
})
|
||||
|
||||
globalDraftStore.deleteDraft(WORKSPACE_A, 'flow', 'f/shared/path')
|
||||
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_A, 'flow', 'f/shared/path')).toBeUndefined()
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_B, 'flow', 'f/shared/path')).toBeDefined()
|
||||
|
||||
globalDraftStore.clearDrafts(WORKSPACE_B)
|
||||
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(0)
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
UserDraft,
|
||||
type UserDraftEntry,
|
||||
type UserDraftItemKind,
|
||||
type UserDraftMeta
|
||||
} from '$lib/userDraft.svelte'
|
||||
import {
|
||||
getWorkspaceItemKey,
|
||||
type AppDraftValue,
|
||||
type ResourceDraftState,
|
||||
type TriggerKind,
|
||||
type TriggerRequestBody,
|
||||
type VariableDraftState,
|
||||
type WorkspaceItem,
|
||||
type WorkspaceItemType
|
||||
} from './workspaceItems'
|
||||
|
||||
const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = {
|
||||
http: 'trigger_http',
|
||||
websocket: 'trigger_websocket',
|
||||
kafka: 'trigger_kafka',
|
||||
nats: 'trigger_nats',
|
||||
postgres: 'trigger_postgres',
|
||||
mqtt: 'trigger_mqtt',
|
||||
sqs: 'trigger_sqs',
|
||||
gcp: 'trigger_gcp',
|
||||
azure: 'trigger_azure'
|
||||
} as const satisfies Record<TriggerKind, UserDraftItemKind>
|
||||
|
||||
const TRIGGER_KIND_BY_DRAFT_KIND = Object.fromEntries(
|
||||
Object.entries(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND).map(([triggerKind, draftKind]) => [
|
||||
draftKind,
|
||||
triggerKind
|
||||
])
|
||||
) as Partial<Record<UserDraftItemKind, TriggerKind>>
|
||||
|
||||
const GLOBAL_DRAFT_KINDS = [
|
||||
'script',
|
||||
'flow',
|
||||
'raw_app',
|
||||
'trigger_schedule',
|
||||
'trigger_http',
|
||||
'trigger_websocket',
|
||||
'trigger_kafka',
|
||||
'trigger_nats',
|
||||
'trigger_postgres',
|
||||
'trigger_mqtt',
|
||||
'trigger_sqs',
|
||||
'trigger_gcp',
|
||||
'trigger_azure',
|
||||
'resource',
|
||||
'variable'
|
||||
] as const satisfies UserDraftItemKind[]
|
||||
|
||||
const secretVariableDraftValues = new Map<string, Map<string, string>>()
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value) as T
|
||||
}
|
||||
|
||||
function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue {
|
||||
return {
|
||||
summary: value.summary,
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: value.data ?? { ...DEFAULT_RAW_APP_DATA },
|
||||
policy: value.policy === undefined ? undefined : clone(value.policy),
|
||||
custom_path: value.custom_path
|
||||
}
|
||||
}
|
||||
|
||||
function getItemSummary(value: unknown): string | undefined {
|
||||
return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined
|
||||
}
|
||||
|
||||
export function setEphemeralSecretVariableDraftValue(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: string
|
||||
): void {
|
||||
let workspaceValues = secretVariableDraftValues.get(workspace)
|
||||
if (!workspaceValues) {
|
||||
workspaceValues = new Map()
|
||||
secretVariableDraftValues.set(workspace, workspaceValues)
|
||||
}
|
||||
workspaceValues.set(path, value)
|
||||
}
|
||||
|
||||
export function getEphemeralSecretVariableDraftValue(
|
||||
workspace: string,
|
||||
path: string
|
||||
): string | undefined {
|
||||
return secretVariableDraftValues.get(workspace)?.get(path)
|
||||
}
|
||||
|
||||
export function clearEphemeralSecretVariableDraftValue(workspace: string, path: string): void {
|
||||
const workspaceValues = secretVariableDraftValues.get(workspace)
|
||||
if (!workspaceValues) return
|
||||
workspaceValues.delete(path)
|
||||
if (workspaceValues.size === 0) secretVariableDraftValues.delete(workspace)
|
||||
}
|
||||
|
||||
function clearEphemeralSecretVariableDraftValues(workspace: string): void {
|
||||
secretVariableDraftValues.delete(workspace)
|
||||
}
|
||||
|
||||
function itemKindFor(
|
||||
type: WorkspaceItemType,
|
||||
triggerKind?: TriggerKind
|
||||
): UserDraftItemKind | undefined {
|
||||
switch (type) {
|
||||
case 'script':
|
||||
case 'flow':
|
||||
case 'resource':
|
||||
case 'variable':
|
||||
return type
|
||||
case 'app':
|
||||
return 'raw_app'
|
||||
case 'schedule':
|
||||
return 'trigger_schedule'
|
||||
case 'trigger':
|
||||
return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind {
|
||||
return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind]
|
||||
}
|
||||
|
||||
function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem {
|
||||
return {
|
||||
type: 'script',
|
||||
path,
|
||||
summary: draft.summary,
|
||||
language: draft.language,
|
||||
value: draft.content,
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem {
|
||||
return {
|
||||
type: 'flow',
|
||||
path,
|
||||
summary: draft.summary,
|
||||
value: {
|
||||
value: draft.value,
|
||||
schema: draft.schema ?? null,
|
||||
groups: draft.value.groups ?? null
|
||||
},
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceItem {
|
||||
const value = normalizeAppDraftValue(draft)
|
||||
return {
|
||||
type: 'app',
|
||||
path,
|
||||
summary: value.summary,
|
||||
value,
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDraftToWorkspaceItem(path: string, draft: NewSchedule): WorkspaceItem {
|
||||
return {
|
||||
type: 'schedule',
|
||||
path,
|
||||
summary: draft.summary ?? undefined,
|
||||
value: clone(draft),
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDraftToWorkspaceItem(
|
||||
kind: TriggerKind,
|
||||
path: string,
|
||||
draft: TriggerRequestBody
|
||||
): WorkspaceItem {
|
||||
return {
|
||||
type: 'trigger',
|
||||
triggerKind: kind,
|
||||
path,
|
||||
summary: getItemSummary(draft),
|
||||
value: clone(draft),
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function resourceDraftToWorkspaceItem(path: string, draft: ResourceDraftState): WorkspaceItem {
|
||||
return {
|
||||
type: 'resource',
|
||||
path,
|
||||
summary: draft.description || undefined,
|
||||
value: {
|
||||
path,
|
||||
value: clone(draft.args),
|
||||
description: draft.description,
|
||||
resource_type: draft.resource_type ?? '',
|
||||
labels: draft.labels,
|
||||
ws_specific: draft.wsSpecific
|
||||
},
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState): WorkspaceItem {
|
||||
return {
|
||||
type: 'variable',
|
||||
path,
|
||||
summary: draft.variable.description || undefined,
|
||||
value: {
|
||||
path,
|
||||
value: draft.variable.value,
|
||||
is_secret: draft.variable.is_secret,
|
||||
description: draft.variable.description,
|
||||
account: draft.account,
|
||||
is_oauth: draft.is_oauth,
|
||||
expires_at: draft.expires_at,
|
||||
labels: draft.labels,
|
||||
ws_specific: draft.wsSpecific
|
||||
},
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function userDraftEntryToWorkspaceItem(
|
||||
entry: UserDraftEntry,
|
||||
path = entry.path,
|
||||
isLiveDraft = false
|
||||
): WorkspaceItem | undefined {
|
||||
let item: WorkspaceItem | undefined
|
||||
switch (entry.itemKind) {
|
||||
case 'script':
|
||||
item = scriptDraftToWorkspaceItem(path, entry.value as NewScript)
|
||||
break
|
||||
case 'flow':
|
||||
item = flowDraftToWorkspaceItem(path, entry.value as Flow)
|
||||
break
|
||||
case 'raw_app':
|
||||
item = appDraftToWorkspaceItem(path, entry.value as AppDraftValue)
|
||||
break
|
||||
case 'trigger_schedule':
|
||||
item = scheduleDraftToWorkspaceItem(path, entry.value as NewSchedule)
|
||||
break
|
||||
case 'resource':
|
||||
item = resourceDraftToWorkspaceItem(path, entry.value as ResourceDraftState)
|
||||
break
|
||||
case 'variable':
|
||||
item = variableDraftToWorkspaceItem(path, entry.value as VariableDraftState)
|
||||
break
|
||||
default: {
|
||||
const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind]
|
||||
item = triggerKind
|
||||
? triggerDraftToWorkspaceItem(triggerKind, path, entry.value as TriggerRequestBody)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
return item && isLiveDraft ? { ...item, isLiveDraft: true } : item
|
||||
}
|
||||
|
||||
function liveDisplayPath(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
storagePath: string
|
||||
): { displayPath: string; isLiveDraft: boolean } {
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (liveDraft?.storagePath !== storagePath) {
|
||||
return { displayPath: storagePath, isLiveDraft: false }
|
||||
}
|
||||
return {
|
||||
displayPath: liveDraft.effectivePath || storagePath,
|
||||
isLiveDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDraftStoragePath(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string
|
||||
): string {
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (!liveDraft) return path
|
||||
if (path === liveDraft.storagePath || path === liveDraft.effectivePath)
|
||||
return liveDraft.storagePath
|
||||
return path
|
||||
}
|
||||
|
||||
export function getGlobalDraftStoragePath(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): string {
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path
|
||||
}
|
||||
|
||||
function getGlobalDraftSlot(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
) {
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
if (!itemKind) return undefined
|
||||
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
|
||||
const draft = UserDraft.get(itemKind, storagePath, { workspace })
|
||||
if (draft === undefined) return undefined
|
||||
|
||||
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
|
||||
const entry = {
|
||||
workspace,
|
||||
itemKind,
|
||||
path: storagePath,
|
||||
value: draft,
|
||||
meta: {},
|
||||
persisted: false,
|
||||
live: false
|
||||
}
|
||||
const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
|
||||
if (!item) return undefined
|
||||
return { itemKind, storagePath, displayPath, item }
|
||||
}
|
||||
|
||||
export function getGlobalDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item
|
||||
}
|
||||
|
||||
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
|
||||
const drafts = new Map<string, WorkspaceItem>()
|
||||
for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
|
||||
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path)
|
||||
const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
|
||||
if (!draft) continue
|
||||
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
|
||||
}
|
||||
return Array.from(drafts.values())
|
||||
}
|
||||
|
||||
export function saveGlobalAppDraft(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue,
|
||||
meta?: UserDraftMeta
|
||||
): WorkspaceItem {
|
||||
const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path)
|
||||
const normalized = normalizeAppDraftValue(value)
|
||||
if (meta) {
|
||||
UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace })
|
||||
} else {
|
||||
UserDraft.save('raw_app', storagePath, normalized, { workspace })
|
||||
}
|
||||
const stored = getGlobalDraft(workspace, 'app', path)
|
||||
if (!stored) throw new Error(`Could not read written app draft "${path}".`)
|
||||
return stored
|
||||
}
|
||||
|
||||
type DeleteGlobalDraftOptions = {
|
||||
preserveLiveDraft?: boolean
|
||||
}
|
||||
|
||||
export function deleteGlobalDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind,
|
||||
options: DeleteGlobalDraftOptions = {}
|
||||
): void {
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
if (!itemKind) return
|
||||
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) {
|
||||
UserDraft.remove(itemKind, storagePath, { workspace })
|
||||
} else {
|
||||
UserDraft.clear(itemKind, storagePath, { workspace })
|
||||
}
|
||||
if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath)
|
||||
}
|
||||
|
||||
export function clearGlobalDrafts(workspace: string): void {
|
||||
for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
|
||||
UserDraft.clear(draft.itemKind, draft.path, { workspace })
|
||||
}
|
||||
clearEphemeralSecretVariableDraftValues(workspace)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
AzureTriggerData,
|
||||
CreateResource,
|
||||
CreateVariable,
|
||||
FlowValue,
|
||||
GcpTriggerData,
|
||||
NewHttpTrigger,
|
||||
NewKafkaTrigger,
|
||||
NewMqttTrigger,
|
||||
NewNatsTrigger,
|
||||
NewPostgresTrigger,
|
||||
NewSchedule,
|
||||
NewSqsTrigger,
|
||||
NewWebsocketTrigger,
|
||||
Policy,
|
||||
ScriptLang
|
||||
} from '$lib/gen/types.gen'
|
||||
|
||||
/**
|
||||
* Flow draft value. Mirrors what the backend's create/update flow API expects
|
||||
* -- the OpenFlow value, plus the inputs schema and (optional) groups.
|
||||
*
|
||||
* Schema and groups are split out from FlowValue intentionally so that
|
||||
* deploy_workspace_item can preserve them through the draft -> workspace
|
||||
* round-trip; an earlier version dropped them on every deploy.
|
||||
*/
|
||||
export type FlowDraftValue = {
|
||||
value: FlowValue
|
||||
schema?: Record<string, any> | null
|
||||
groups?: NonNullable<FlowValue['groups']> | null
|
||||
}
|
||||
|
||||
export const TRIGGER_KINDS = [
|
||||
'http',
|
||||
'websocket',
|
||||
'kafka',
|
||||
'nats',
|
||||
'postgres',
|
||||
'mqtt',
|
||||
'sqs',
|
||||
'gcp',
|
||||
'azure'
|
||||
] as const
|
||||
|
||||
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
|
||||
|
||||
export type TriggerRequestBody =
|
||||
| NewHttpTrigger
|
||||
| NewWebsocketTrigger
|
||||
| NewKafkaTrigger
|
||||
| NewNatsTrigger
|
||||
| NewPostgresTrigger
|
||||
| NewMqttTrigger
|
||||
| NewSqsTrigger
|
||||
| GcpTriggerData
|
||||
| AzureTriggerData
|
||||
|
||||
export type WorkspaceItemType =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'schedule'
|
||||
| 'trigger'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'app'
|
||||
|
||||
export type AppDraftValue = {
|
||||
summary?: string
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, any>
|
||||
data?: any
|
||||
policy?: Policy
|
||||
custom_path?: string
|
||||
}
|
||||
|
||||
export type ResourceDraftState = {
|
||||
path: string
|
||||
description: string
|
||||
args: Record<string, any>
|
||||
labels: string[] | undefined
|
||||
wsSpecific: boolean
|
||||
resource_type?: string
|
||||
}
|
||||
|
||||
export type VariableDraftState = {
|
||||
path: string
|
||||
variable: { value: string; is_secret: boolean; description: string }
|
||||
labels: string[] | undefined
|
||||
wsSpecific: boolean
|
||||
account?: number
|
||||
is_oauth?: boolean
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
export type WorkspaceItem = {
|
||||
type: WorkspaceItemType
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
triggerKind?: TriggerKind
|
||||
value?:
|
||||
| string
|
||||
| FlowDraftValue
|
||||
| NewSchedule
|
||||
| TriggerRequestBody
|
||||
| CreateResource
|
||||
| CreateVariable
|
||||
| AppDraftValue
|
||||
isDraft: boolean
|
||||
isLiveDraft?: boolean
|
||||
}
|
||||
|
||||
export function getWorkspaceItemKey(
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): string {
|
||||
if (type === 'trigger') {
|
||||
return `trigger:${triggerKind ?? ''}:${path}`
|
||||
}
|
||||
return `${type}:${path}`
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export type FlowBuilderProps = {
|
||||
stepsState: Record<string, stepState>
|
||||
}
|
||||
noInitial?: boolean
|
||||
liveEditorDraftStoragePath?: string
|
||||
onSaveInitial?: ({ path, id }: { path: string; id: string }) => void
|
||||
onSaveDraft?: ({
|
||||
path,
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
* key when the editor is rendered in a context that wants its own
|
||||
* preference. */
|
||||
sidebarStorageKey?: string
|
||||
liveEditorDraftStoragePath?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -84,7 +85,8 @@
|
||||
savedApp = $bindable(undefined),
|
||||
diffDrawer = undefined,
|
||||
defaultSidebarCollapsed = false,
|
||||
sidebarStorageKey = 'raw-app-sidebar-collapsed'
|
||||
sidebarStorageKey = 'raw-app-sidebar-collapsed',
|
||||
liveEditorDraftStoragePath = undefined
|
||||
}: Props = $props()
|
||||
export const version: number | undefined = undefined
|
||||
|
||||
@@ -934,6 +936,7 @@
|
||||
{newApp}
|
||||
{newPath}
|
||||
appPath={path}
|
||||
{liveEditorDraftStoragePath}
|
||||
{files}
|
||||
{data}
|
||||
{runnables}
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
onOpenYamlEditor?: () => void
|
||||
sidebarCollapsed?: boolean
|
||||
onToggleSidebar?: () => void
|
||||
liveEditorDraftStoragePath?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -148,7 +149,8 @@
|
||||
onRedo = undefined,
|
||||
onOpenYamlEditor = undefined,
|
||||
sidebarCollapsed = false,
|
||||
onToggleSidebar = undefined
|
||||
onToggleSidebar = undefined,
|
||||
liveEditorDraftStoragePath = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let newEditedPath = $state(
|
||||
@@ -159,6 +161,22 @@
|
||||
)
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
|
||||
const workspace = $workspaceStore
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace,
|
||||
itemKind: 'raw_app',
|
||||
storagePath: liveEditorDraftStoragePath,
|
||||
effectivePath: newEditedPath || appPath || savedApp?.path
|
||||
})
|
||||
return () =>
|
||||
UserDraft.clearLiveEditorDraft('raw_app', {
|
||||
workspace,
|
||||
storagePath: liveEditorDraftStoragePath
|
||||
})
|
||||
})
|
||||
|
||||
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
|
||||
let deployedBy: string | undefined = $state(undefined) // Author
|
||||
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
|
||||
|
||||
@@ -130,7 +130,26 @@ export type UserDraftEntry<V = unknown> = {
|
||||
live: boolean
|
||||
}
|
||||
|
||||
export type LiveEditorDraft = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
storagePath: string
|
||||
effectivePath?: string
|
||||
}
|
||||
|
||||
export type LiveEditorDraftSpec = {
|
||||
itemKind: UserDraftItemKind
|
||||
storagePath: string
|
||||
effectivePath?: string
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
export type ClearLiveEditorDraftOptions = UserDraftOptions & {
|
||||
storagePath?: string
|
||||
}
|
||||
|
||||
const entries = new Map<string, DraftEntry>()
|
||||
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
|
||||
|
||||
function resolveWorkspace(opts?: UserDraftOptions): string {
|
||||
const ws = opts?.workspace ?? get(workspaceStore)
|
||||
@@ -230,6 +249,10 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s
|
||||
return `userdraft/w/${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
|
||||
function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string {
|
||||
return `${workspace}/${itemKind}`
|
||||
}
|
||||
|
||||
function parseLocalStorageKey(
|
||||
key: string,
|
||||
workspace: string,
|
||||
@@ -331,10 +354,13 @@ export const UserDraft = {
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Notify observers; preserve existing rev metadata. `untrack`ed
|
||||
// read — see `set draft` below for why.
|
||||
// Static writes are external mutations. Update live observers and
|
||||
// force the storage slot to match, even if the live entry still has
|
||||
// its initial-write skip armed.
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
entry.state.val = wrap(value, extractMeta(current))
|
||||
const meta = extractMeta(current)
|
||||
entry.state.setWithoutPersist(wrap(value, meta))
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
return
|
||||
}
|
||||
// No live handle: preserve any persisted meta so the staleness
|
||||
@@ -361,10 +387,10 @@ export const UserDraft = {
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
entry.state.val = wrap(value, meta)
|
||||
// Static writes represent explicit external draft mutations. A
|
||||
// freshly acquired live entry may still have the initial-write skip
|
||||
// armed, so force the storage slot to match the live value.
|
||||
entry.state.setWithoutPersist(wrap(value, meta))
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
return
|
||||
}
|
||||
@@ -401,9 +427,9 @@ export const UserDraft = {
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
return unwrap(entry.state.val as StoredDraft<V> | undefined)
|
||||
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
|
||||
}
|
||||
return unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path)))
|
||||
return snapshotDraftValue(unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path))))
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -523,6 +549,34 @@ export const UserDraft = {
|
||||
return Array.from(out.values())
|
||||
},
|
||||
|
||||
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
|
||||
const ws = resolveWorkspace({ workspace: spec.workspace })
|
||||
liveEditorDrafts.set(liveEditorDraftKey(ws, spec.itemKind), {
|
||||
workspace: ws,
|
||||
itemKind: spec.itemKind,
|
||||
storagePath: spec.storagePath,
|
||||
effectivePath: spec.effectivePath || undefined
|
||||
})
|
||||
},
|
||||
|
||||
getLiveEditorDraft(
|
||||
itemKind: UserDraftItemKind,
|
||||
opts?: UserDraftOptions
|
||||
): LiveEditorDraft | undefined {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const draft = liveEditorDrafts.get(liveEditorDraftKey(ws, itemKind))
|
||||
return draft ? { ...draft } : undefined
|
||||
},
|
||||
|
||||
clearLiveEditorDraft(itemKind: UserDraftItemKind, opts?: ClearLiveEditorDraftOptions): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const key = liveEditorDraftKey(ws, itemKind)
|
||||
const draft = liveEditorDrafts.get(key)
|
||||
if (!draft) return
|
||||
if (opts?.storagePath !== undefined && draft.storagePath !== opts.storagePath) return
|
||||
liveEditorDrafts.delete(key)
|
||||
},
|
||||
|
||||
/**
|
||||
* Like `remove`, but also resets any live handle's `draft` to
|
||||
* `fallback` in-memory (so reactive readers see it immediately) and
|
||||
@@ -802,4 +856,5 @@ export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void
|
||||
/** Test-only: clear all in-memory entries. */
|
||||
export function __resetUserDraftForTesting(): void {
|
||||
entries.clear()
|
||||
liveEditorDrafts.clear()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('svelte', async (importOriginal) => {
|
||||
const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } =
|
||||
await import('./userDraft.svelte')
|
||||
const { workspaceStore } = await import('./stores')
|
||||
const { deleteGlobalDraft } = await import('./components/copilot/chat/global/userDraftAdapter')
|
||||
|
||||
function flushDestroyCallbacks(): void {
|
||||
const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length)
|
||||
@@ -119,6 +120,78 @@ describe('UserDraft.save / get / remove (no observers)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft live editor draft registry', () => {
|
||||
it('stores the live editor storage path and effective path per workspace and kind', () => {
|
||||
UserDraft.setLiveEditorDraft({
|
||||
itemKind: 'script',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/me/generated_script'
|
||||
})
|
||||
|
||||
expect(UserDraft.getLiveEditorDraft('script')).toEqual({
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/me/generated_script'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps live editor registrations isolated by workspace', () => {
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: 'ws_a',
|
||||
itemKind: 'flow',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/me/a'
|
||||
})
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: 'ws_b',
|
||||
itemKind: 'flow',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/me/b'
|
||||
})
|
||||
|
||||
expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_a' })?.effectivePath).toBe(
|
||||
'u/me/a'
|
||||
)
|
||||
expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_b' })?.effectivePath).toBe(
|
||||
'u/me/b'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears only the matching live editor storage path when provided', () => {
|
||||
UserDraft.setLiveEditorDraft({
|
||||
itemKind: 'raw_app',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/me/live_app'
|
||||
})
|
||||
|
||||
UserDraft.clearLiveEditorDraft('raw_app', { storagePath: 'u/me/other' })
|
||||
expect(UserDraft.getLiveEditorDraft('raw_app')).toBeDefined()
|
||||
|
||||
UserDraft.clearLiveEditorDraft('raw_app', { storagePath: '' })
|
||||
expect(UserDraft.getLiveEditorDraft('raw_app')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can remove persisted global draft storage without blanking the live editor', () => {
|
||||
const draft = { path: 'u/me/live_script', content: 'export async function main() {}' }
|
||||
localStorage.setItem('userdraft/w/test_ws/script/', wrapped(draft))
|
||||
const handle = UserDraft.use<typeof draft>('script', '')
|
||||
UserDraft.setLiveEditorDraft({
|
||||
itemKind: 'script',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/me/live_script'
|
||||
})
|
||||
|
||||
deleteGlobalDraft('test_ws', 'script', 'u/me/live_script', undefined, {
|
||||
preserveLiveDraft: true
|
||||
})
|
||||
flushPersist()
|
||||
|
||||
expect(handle.draft).toEqual(draft)
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/script/')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.use() — observer sync', () => {
|
||||
it('loads the existing localStorage value on first use', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded'))
|
||||
@@ -138,24 +211,29 @@ describe('UserDraft.use() — observer sync', () => {
|
||||
expect(a.draft).toBe(99)
|
||||
})
|
||||
|
||||
it('save() propagates to live use() handles (in-memory)', () => {
|
||||
it('save() propagates to live use() handles and persists immediately', () => {
|
||||
const handle = UserDraft.use<number>('flow', 'u/me/observed')
|
||||
expect(handle.draft).toBeUndefined()
|
||||
|
||||
// First write through a live entry is treated as the "initial value"
|
||||
// (saveInitialValue=false) and is NOT persisted — observers still see it.
|
||||
UserDraft.save('flow', 'u/me/observed', 7)
|
||||
expect(handle.draft).toBe(7)
|
||||
flushPersist()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBeNull()
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(7))
|
||||
|
||||
// Subsequent writes persist.
|
||||
UserDraft.save('flow', 'u/me/observed', 9)
|
||||
expect(handle.draft).toBe(9)
|
||||
flushPersist()
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9))
|
||||
})
|
||||
|
||||
it('get() returns a cloneable snapshot of live handle values', () => {
|
||||
const handle = UserDraft.use<{ path: string; nested: { value: number } }>('script', '')
|
||||
handle.draft = { path: 'u/me/live', nested: { value: 1 } }
|
||||
|
||||
const draft = UserDraft.get<{ path: string; nested: { value: number } }>('script', '')
|
||||
expect(draft).toEqual({ path: 'u/me/live', nested: { value: 1 } })
|
||||
expect(draft).not.toBe(handle.draft)
|
||||
expect(() => structuredClone(draft)).not.toThrow()
|
||||
})
|
||||
|
||||
it('remove() clears localStorage without touching the in-memory handle', () => {
|
||||
// Seed localStorage so the live handle initialises from it.
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1))
|
||||
@@ -411,6 +489,28 @@ describe('UserDraft — rev metadata for staleness checks', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('UserDraft.save persists immediately when a live handle exists', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/live-save')
|
||||
|
||||
UserDraft.save('flow', 'u/me/live-save', 'external')
|
||||
|
||||
expect(handle.draft).toBe('external')
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save')).toBe(wrapped('external'))
|
||||
})
|
||||
|
||||
it('UserDraft.save preserves live rev metadata while forcing persistence', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/live-save-meta')
|
||||
handle.setDraftAndMeta('baseline', { remoteRev: 5 })
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBeNull()
|
||||
|
||||
UserDraft.save('flow', 'u/me/live-save-meta', 'external')
|
||||
|
||||
expect(handle.draft).toBe('external')
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBe(
|
||||
JSON.stringify({ value: 'external', remoteRev: 5 })
|
||||
)
|
||||
})
|
||||
|
||||
it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => {
|
||||
localStorage.setItem(
|
||||
'userdraft/w/test_ws/flow/u/me/legacy',
|
||||
@@ -503,8 +603,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => {
|
||||
|
||||
UserDraft.save('flow', 'u/me/ref', 2)
|
||||
expect(a.draft).toBe(2)
|
||||
// Now persisted (second write after the baseline).
|
||||
flushPersist()
|
||||
// External save() calls persist immediately, even with a live handle.
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2))
|
||||
|
||||
// Releasing the second handle drops the entry; subsequent save()
|
||||
@@ -878,9 +977,7 @@ describe('UserDraft.list / clear / setDraftAndMeta', () => {
|
||||
)
|
||||
|
||||
flushPersist()
|
||||
expect(storedShape(key)).toBe(
|
||||
wrapped({ path: 'f/rewrite-after-clear', content: 'new' })
|
||||
)
|
||||
expect(storedShape(key)).toBe(wrapped({ path: 'f/rewrite-after-clear', content: 'new' }))
|
||||
})
|
||||
|
||||
it('list hides persisted drafts when a live handle has cleared the value', () => {
|
||||
|
||||
@@ -79,6 +79,8 @@
|
||||
runnables: Record<string, Runnable>
|
||||
data: RawAppData
|
||||
summary: string
|
||||
policy?: Policy
|
||||
custom_path?: string
|
||||
}>('raw_app', '')
|
||||
// Restore the persisted autosave so a plain reload of /apps_raw/add
|
||||
// resumes the last session. Captured once; the $effect below mirrors
|
||||
@@ -114,13 +116,15 @@
|
||||
|
||||
let summary = $state(restoredDraft?.summary ?? '')
|
||||
let files: Record<string, string> = $state(restoredDraft?.files ?? react19Template)
|
||||
let policy: Policy = $state({
|
||||
on_behalf_of: $userStore?.username.includes('@')
|
||||
? $userStore?.username
|
||||
: `u/${$userStore?.username}`,
|
||||
on_behalf_of_email: $userStore?.email,
|
||||
execution_mode: 'publisher'
|
||||
})
|
||||
let policy: Policy = $state(
|
||||
restoredDraft?.policy ?? {
|
||||
on_behalf_of: $userStore?.username.includes('@')
|
||||
? $userStore?.username
|
||||
: `u/${$userStore?.username}`,
|
||||
on_behalf_of_email: $userStore?.email,
|
||||
execution_mode: 'publisher'
|
||||
}
|
||||
)
|
||||
|
||||
let runnables: Record<string, Runnable> = $state(restoredDraft?.runnables ?? defaultRunnables)
|
||||
/** Data configuration including tables and creation policy */
|
||||
@@ -133,13 +137,14 @@
|
||||
readFieldsRecursively(files)
|
||||
readFieldsRecursively(runnables)
|
||||
readFieldsRecursively(data)
|
||||
readFieldsRecursively(policy)
|
||||
void summary
|
||||
untrack(() => {
|
||||
if (firstMirror) {
|
||||
firstMirror = false
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
}
|
||||
draftHandle.draft = { files, runnables, data, summary }
|
||||
draftHandle.draft = { files, runnables, data, summary, policy }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,11 +155,12 @@
|
||||
const d = draftHandle.draft
|
||||
if (d == null) return
|
||||
untrack(() => {
|
||||
if (localDraftDiffers(d, { files, runnables, data, summary })) {
|
||||
if (localDraftDiffers(d, { files, runnables, data, summary, policy })) {
|
||||
files = d.files
|
||||
runnables = d.runnables
|
||||
data = d.data
|
||||
summary = d.summary
|
||||
if (d.policy !== undefined) policy = d.policy
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -666,6 +672,7 @@
|
||||
bind:data
|
||||
{policy}
|
||||
path={''}
|
||||
liveEditorDraftStoragePath=""
|
||||
bind:summary
|
||||
newApp
|
||||
/>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
runnables: Record<string, any>
|
||||
data: RawAppData
|
||||
summary: string
|
||||
policy?: any
|
||||
custom_path?: string
|
||||
}
|
||||
|
||||
let files: Record<string, string> | undefined = $state(undefined)
|
||||
@@ -105,25 +107,45 @@
|
||||
|
||||
// Persist the bundle whenever any of the four pieces of state changes.
|
||||
$effect(() => {
|
||||
if (!files) return
|
||||
readFieldsRecursively(files)
|
||||
const currentFiles = files
|
||||
if (!currentFiles) return
|
||||
readFieldsRecursively(currentFiles)
|
||||
readFieldsRecursively(runnables)
|
||||
readFieldsRecursively(data)
|
||||
readFieldsRecursively(policy)
|
||||
void summary
|
||||
draftHandle.draft = { files, runnables, data, summary }
|
||||
draftHandle.draft = {
|
||||
files: currentFiles,
|
||||
runnables,
|
||||
data,
|
||||
summary,
|
||||
policy,
|
||||
custom_path: savedApp?.custom_path
|
||||
}
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.save into the form. Idempotent; the
|
||||
// `!files` guard skips the reload window so it doesn't fight loadApp.
|
||||
$effect(() => {
|
||||
const d = draftHandle.draft
|
||||
if (d == null || !files) return
|
||||
const currentFiles = files
|
||||
if (d == null || !currentFiles) return
|
||||
untrack(() => {
|
||||
if (localDraftDiffers(d, { files, runnables, data, summary })) {
|
||||
if (
|
||||
localDraftDiffers(d, {
|
||||
files: currentFiles,
|
||||
runnables,
|
||||
data,
|
||||
summary,
|
||||
policy,
|
||||
custom_path: savedApp?.custom_path
|
||||
})
|
||||
) {
|
||||
files = d.files
|
||||
runnables = d.runnables
|
||||
data = d.data
|
||||
summary = d.summary
|
||||
if (d.policy !== undefined) policy = d.policy
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -192,7 +214,9 @@
|
||||
(backendSource.value?.datatables
|
||||
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
|
||||
: { ...DEFAULT_DATA }),
|
||||
summary: backendSource.summary ?? ''
|
||||
summary: backendSource.summary ?? '',
|
||||
policy: backendSource.policy ?? app_w_draft.policy,
|
||||
custom_path: backendSource.custom_path ?? app_w_draft.custom_path
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -240,7 +264,7 @@
|
||||
runnables = localDraft.runnables
|
||||
data = localDraft.data
|
||||
summary = localDraft.summary
|
||||
policy = app_w_draft.policy
|
||||
policy = localDraft.policy ?? app_w_draft.policy
|
||||
newPath = app_w_draft.path
|
||||
files = localDraft.files
|
||||
} else {
|
||||
@@ -370,6 +394,7 @@
|
||||
bind:summary
|
||||
{newPath}
|
||||
path={page.params.path ?? ''}
|
||||
liveEditorDraftStoragePath={path}
|
||||
{policy}
|
||||
bind:savedApp
|
||||
{diffDrawer}
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
onNavigate={(item) => goto(editPathFor(item))}
|
||||
{initialPath}
|
||||
{pathStoreInit}
|
||||
liveEditorDraftStoragePath=""
|
||||
bind:this={flowBuilder}
|
||||
newFlow
|
||||
{initialArgs}
|
||||
|
||||
@@ -367,6 +367,7 @@
|
||||
{flowStore}
|
||||
{flowStateStore}
|
||||
initialPath={page.params.path ?? ''}
|
||||
liveEditorDraftStoragePath={flowDraftPath}
|
||||
newFlow={false}
|
||||
{selectedId}
|
||||
{initialArgs}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import {
|
||||
globalDraftStore,
|
||||
type WorkspaceItem
|
||||
} from '$lib/components/copilot/chat/global/draftStore.svelte'
|
||||
clearGlobalDrafts,
|
||||
deleteGlobalDraft,
|
||||
listGlobalDrafts
|
||||
} from '$lib/components/copilot/chat/global/userDraftAdapter'
|
||||
import type { WorkspaceItem } from '$lib/components/copilot/chat/global/workspaceItems'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -11,6 +13,11 @@
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
let enabled = $state(false)
|
||||
let refreshToken = $state(0)
|
||||
|
||||
function refreshDrafts() {
|
||||
refreshToken += 1
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Dev-only route. Bounce to home when the global mode gate is closed.
|
||||
@@ -18,9 +25,24 @@
|
||||
if (!enabled) {
|
||||
goto('/')
|
||||
}
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key?.startsWith('userdraft/')) refreshDrafts()
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
// Same-tab saves and live editor registry changes don't emit `storage`.
|
||||
const interval = window.setInterval(refreshDrafts, 1000)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage)
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
let drafts = $derived($workspaceStore ? globalDraftStore.listDrafts($workspaceStore) : [])
|
||||
let drafts = $derived.by(() => {
|
||||
refreshToken
|
||||
return $workspaceStore ? listGlobalDrafts($workspaceStore) : []
|
||||
})
|
||||
|
||||
function draftKey(item: WorkspaceItem): string {
|
||||
return `${item.type}:${item.triggerKind ?? '-'}:${item.path}`
|
||||
@@ -28,12 +50,14 @@
|
||||
|
||||
function deleteDraft(item: WorkspaceItem) {
|
||||
if (!$workspaceStore) return
|
||||
globalDraftStore.deleteDraft($workspaceStore, item.type, item.path, item.triggerKind)
|
||||
deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
|
||||
refreshDrafts()
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!$workspaceStore) return
|
||||
globalDraftStore.clearDrafts($workspaceStore)
|
||||
clearGlobalDrafts($workspaceStore)
|
||||
refreshDrafts()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -41,9 +65,9 @@
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Global AI drafts</h1>
|
||||
<h1 class="text-2xl font-semibold">Global local drafts</h1>
|
||||
<p class="text-sm text-tertiary">
|
||||
Dev-only inspector for the in-memory global draft store.
|
||||
Dev-only inspector for global local drafts.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -112,6 +112,18 @@
|
||||
defaultValue: templatePath || hubPath || urlScript ? undefined : defaultScript()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!$workspaceStore) return
|
||||
const workspace = $workspaceStore
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace,
|
||||
itemKind: 'script',
|
||||
storagePath: '',
|
||||
effectivePath: scriptHandle.draft?.path
|
||||
})
|
||||
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: '' })
|
||||
})
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
// Legacy behavior: the URL hash both seeds the editor on load AND stays in
|
||||
// sync with edits (encoded back into the hash, debounced). Asks the user
|
||||
|
||||
@@ -39,6 +39,18 @@
|
||||
const draftPath = hash ? '' : (page.params.path ?? '')
|
||||
const scriptHandle = UserDraft.use<EditableScript>('script', draftPath)
|
||||
|
||||
$effect(() => {
|
||||
if (hash || !$workspaceStore) return
|
||||
const workspace = $workspaceStore
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace,
|
||||
itemKind: 'script',
|
||||
storagePath: draftPath,
|
||||
effectivePath: scriptHandle.draft?.path ?? draftPath
|
||||
})
|
||||
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: draftPath })
|
||||
})
|
||||
|
||||
/** Some pages base64-JSON-encode a NewScript-like payload into the URL
|
||||
* hash on `/scripts/edit/<path>#…`. Treat it as a one-shot seed that
|
||||
* wins over local autosave + backend draft + deployed: apply, toast,
|
||||
|
||||
Reference in New Issue
Block a user