mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
fix: mark global ai drafts explicitly
This commit is contained in:
@@ -120,6 +120,7 @@ vi.mock('$lib/gen', async () => {
|
||||
})
|
||||
|
||||
import { globalTools, prepareGlobalUserMessage } from './core'
|
||||
import { deleteGlobalDraft, listGlobalDrafts } from './userDraftAdapter'
|
||||
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
|
||||
import type { Tool, ToolCallbacks } from '../shared'
|
||||
|
||||
@@ -257,6 +258,102 @@ describe('global AI tools', () => {
|
||||
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/open-editor`)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat a clean live editor baseline as a deployable global draft', async () => {
|
||||
const content = 'export async function main() {\n\treturn "baseline"\n}'
|
||||
const handle = UserDraft.use<any>('script', 'f/scripts/baseline', { workspace: WORKSPACE })
|
||||
handle.setDraftAndMeta(
|
||||
{
|
||||
path: 'f/scripts/baseline',
|
||||
summary: 'Clean baseline',
|
||||
language: 'bun',
|
||||
content
|
||||
},
|
||||
{ remoteRev: 'v1' }
|
||||
)
|
||||
|
||||
expect(listGlobalDrafts(WORKSPACE)).toEqual([])
|
||||
await expect(
|
||||
callGlobalTool('deploy_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/baseline'
|
||||
})
|
||||
).rejects.toThrow('No AI draft found for script "f/scripts/baseline".')
|
||||
})
|
||||
|
||||
it('does not treat a persisted editor baseline as a deployable global draft', async () => {
|
||||
const key = `userdraft/w/${WORKSPACE}/script/f/scripts/persisted-baseline`
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
value: {
|
||||
path: 'f/scripts/persisted-baseline',
|
||||
summary: 'Persisted baseline',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {\n\treturn "baseline"\n}',
|
||||
parent_hash: 'h1'
|
||||
},
|
||||
remoteRev: 'h1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(listGlobalDrafts(WORKSPACE)).toEqual([])
|
||||
await expect(
|
||||
callGlobalTool('deploy_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/persisted-baseline'
|
||||
})
|
||||
).rejects.toThrow('No AI draft found for script "f/scripts/persisted-baseline".')
|
||||
})
|
||||
|
||||
it('persists AI writes into a live editor handle so they remain deployable', async () => {
|
||||
const handle = UserDraft.use<any>('script', 'f/scripts/live-ai', { workspace: WORKSPACE })
|
||||
handle.setDraftAndMeta(
|
||||
{
|
||||
path: 'f/scripts/live-ai',
|
||||
summary: 'Clean baseline',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {\n\treturn 1\n}'
|
||||
},
|
||||
{ remoteRev: 'v1' }
|
||||
)
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/live-ai',
|
||||
summary: 'AI edit',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {\n\treturn 2\n}'
|
||||
})
|
||||
|
||||
expect(handle.draft?.content).toContain('return 2')
|
||||
expect(listGlobalDrafts(WORKSPACE)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'f/scripts/live-ai',
|
||||
summary: 'AI edit',
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/live-ai`)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('deleteGlobalDraft clears both persisted storage and any live handle state', async () => {
|
||||
const handle = UserDraft.use<any>('script', 'f/scripts/delete-live', { workspace: WORKSPACE })
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/delete-live',
|
||||
summary: 'Delete me',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {\n\treturn "delete"\n}'
|
||||
})
|
||||
expect(handle.draft).toBeDefined()
|
||||
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/delete-live`)).not.toBeNull()
|
||||
|
||||
deleteGlobalDraft(WORKSPACE, 'script', 'f/scripts/delete-live')
|
||||
|
||||
expect(handle.draft).toBeUndefined()
|
||||
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/delete-live`)).toBeNull()
|
||||
})
|
||||
|
||||
it('lists and edits a new script draft stored under the empty add-editor key by its assigned path', async () => {
|
||||
const content = 'export async function main() {\n\treturn 1\n}'
|
||||
const handle = UserDraft.use<any>('script', '', { workspace: WORKSPACE })
|
||||
@@ -348,11 +445,49 @@ describe('global AI tools', () => {
|
||||
type: 'script',
|
||||
path: 'f/scripts/listed',
|
||||
summary: 'Listed script',
|
||||
isDraft: false
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps local UserDraft overlays marked as drafts after path_prefix filtering', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/listed',
|
||||
summary: 'Listed script',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return 1 }'
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script'],
|
||||
path_prefix: 'f/scripts'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'f/scripts/listed',
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('applies path_prefix to local UserDraft overlays in list_workspace_items', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/listed',
|
||||
summary: 'Listed script',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return 1 }'
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script'],
|
||||
path_prefix: 'f/other'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not inspect unrelated partial flow UserDraft entries when listing scripts', async () => {
|
||||
UserDraft.save<any>('flow', 'f/flows/partial', {}, { workspace: WORKSPACE })
|
||||
|
||||
@@ -463,7 +598,7 @@ describe('global AI tools', () => {
|
||||
triggerKind: 'http',
|
||||
path: 'f/triggers/hook',
|
||||
summary: 'Hook trigger',
|
||||
isDraft: false
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
getGlobalDraft,
|
||||
getGlobalDraftStoragePath,
|
||||
listGlobalCurrentItems,
|
||||
listGlobalDrafts,
|
||||
triggerKindToUserDraftKind
|
||||
} from './userDraftAdapter'
|
||||
|
||||
@@ -515,6 +516,13 @@ function itemMatches(
|
||||
)
|
||||
}
|
||||
|
||||
function itemMatchesPathPrefix(
|
||||
item: Pick<WorkspaceItem, 'path'>,
|
||||
pathPrefix: string | undefined
|
||||
): boolean {
|
||||
return !pathPrefix || item.path.startsWith(pathPrefix)
|
||||
}
|
||||
|
||||
function scriptToItem(script: Script, includeValue: boolean): WorkspaceItem {
|
||||
return {
|
||||
type: 'script',
|
||||
@@ -1279,7 +1287,19 @@ export const globalTools: Tool<{}>[] = [
|
||||
)
|
||||
}
|
||||
|
||||
for (const draftItem of listGlobalDrafts(workspace)) {
|
||||
if (!types.includes(draftItem.type)) continue
|
||||
byKey.set(
|
||||
getWorkspaceItemKey(draftItem.type, draftItem.path, draftItem.triggerKind),
|
||||
{
|
||||
...draftItem,
|
||||
value: undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const results = Array.from(byKey.values())
|
||||
.filter((item) => itemMatchesPathPrefix(item, parsed.path_prefix))
|
||||
.filter((item) => itemMatches(item, parsed.query))
|
||||
.slice(0, limit)
|
||||
|
||||
@@ -1795,7 +1815,7 @@ async function writeScriptDraft(
|
||||
}
|
||||
}
|
||||
|
||||
UserDraft.save('script', draftStoragePath, draft, { workspace })
|
||||
UserDraft.saveExternal('script', draftStoragePath, draft, { workspace })
|
||||
return finishDraftWrite(
|
||||
getRequiredGlobalDraft(workspace, 'script', args.path),
|
||||
existingDraft !== undefined || backendExists,
|
||||
@@ -1853,7 +1873,7 @@ async function writeFlowDraft(
|
||||
}
|
||||
}
|
||||
|
||||
UserDraft.save('flow', draftStoragePath, draft, { workspace })
|
||||
UserDraft.saveExternal('flow', draftStoragePath, draft, { workspace })
|
||||
return finishDraftWrite(
|
||||
getRequiredGlobalDraft(workspace, 'flow', args.path),
|
||||
existingDraft !== undefined || backendExists,
|
||||
@@ -1871,7 +1891,7 @@ async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promis
|
||||
? false
|
||||
: await ScheduleService.existsSchedule({ workspace, path: args.path })
|
||||
|
||||
UserDraft.save('trigger_schedule', args.path, draft, { workspace })
|
||||
UserDraft.saveExternal('trigger_schedule', args.path, draft, { workspace })
|
||||
return finishDraftWrite(
|
||||
getRequiredGlobalDraft(workspace, 'schedule', args.path),
|
||||
existingDraft !== undefined || backendExists,
|
||||
@@ -1894,7 +1914,7 @@ async function writeTriggerDraft(
|
||||
? false
|
||||
: await triggerServices[kind].exists({ workspace, path: args.path })
|
||||
|
||||
UserDraft.save(itemKind, args.path, draft, { workspace })
|
||||
UserDraft.saveExternal(itemKind, args.path, draft, { workspace })
|
||||
return finishDraftWrite(
|
||||
getRequiredGlobalDraft(workspace, 'trigger', args.path, kind),
|
||||
existingDraft !== undefined || backendExists,
|
||||
@@ -1915,7 +1935,7 @@ async function writeResourceDraft(
|
||||
? false
|
||||
: await ResourceService.existsResource({ workspace, path: args.path })
|
||||
|
||||
UserDraft.save('resource', args.path, draft, { workspace })
|
||||
UserDraft.saveExternal('resource', args.path, draft, { workspace })
|
||||
return finishDraftWrite(
|
||||
getRequiredGlobalDraft(workspace, 'resource', args.path),
|
||||
existingDraft !== undefined || backendExists,
|
||||
@@ -1936,7 +1956,7 @@ async function writeVariableDraft(
|
||||
? false
|
||||
: await VariableService.existsVariable({ workspace, path: args.path })
|
||||
|
||||
UserDraft.save('variable', args.path, draft, { workspace })
|
||||
UserDraft.saveExternal('variable', args.path, draft, { workspace })
|
||||
return finishDraftWrite(
|
||||
getRequiredGlobalDraft(workspace, 'variable', args.path),
|
||||
existingDraft !== undefined || backendExists,
|
||||
@@ -1945,7 +1965,7 @@ async function writeVariableDraft(
|
||||
}
|
||||
|
||||
function saveAppDraft(workspace: string, path: string, value: AppDraftValue): WorkspaceItem {
|
||||
UserDraft.save('raw_app', path, normalizeAppDraftValue(value), { workspace })
|
||||
UserDraft.saveExternal('raw_app', path, normalizeAppDraftValue(value), { workspace })
|
||||
return getRequiredGlobalDraft(workspace, 'app', path)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import type {
|
||||
NewSchedule,
|
||||
NewScript
|
||||
} from '$lib/gen/types.gen'
|
||||
import { UserDraft, type UserDraftItemKind, type UserDraftListEntry } from '$lib/userDraft.svelte'
|
||||
import {
|
||||
UserDraft,
|
||||
type UserDraftEntrySource,
|
||||
type UserDraftItemKind,
|
||||
type UserDraftListEntry
|
||||
} from '$lib/userDraft.svelte'
|
||||
import {
|
||||
getWorkspaceItemKey,
|
||||
type AppDraftValue,
|
||||
@@ -66,7 +71,6 @@ const SHARED_DRAFT_KINDS = [
|
||||
'variable'
|
||||
] as const satisfies UserDraftItemKind[]
|
||||
const DEFAULT_APP_DATA = { tables: [], datatable: undefined, schema: undefined }
|
||||
const storagePathByVisibleItem = new Map<string, string>()
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value) as T
|
||||
@@ -150,31 +154,6 @@ function getItemSummary(value: unknown): string | undefined {
|
||||
return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined
|
||||
}
|
||||
|
||||
function storagePathCacheKey(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): string {
|
||||
return `${workspace}:${getWorkspaceItemKey(type, path, triggerKind)}`
|
||||
}
|
||||
|
||||
function rememberStoragePath(workspace: string, item: WorkspaceItem, storagePath: string): void {
|
||||
storagePathByVisibleItem.set(
|
||||
storagePathCacheKey(workspace, item.type, item.path, item.triggerKind),
|
||||
storagePath
|
||||
)
|
||||
}
|
||||
|
||||
function forgetStoragePath(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): void {
|
||||
storagePathByVisibleItem.delete(storagePathCacheKey(workspace, type, path, triggerKind))
|
||||
}
|
||||
|
||||
function getItemPath(storagePath: string, value: unknown): string | undefined {
|
||||
const valuePath = (value as { path?: string | null } | undefined)?.path
|
||||
return valuePath?.trim() || storagePath || undefined
|
||||
@@ -332,23 +311,23 @@ function sharedDraftEntryToWorkspaceItem(
|
||||
): WorkspaceItem | undefined {
|
||||
switch (entry.itemKind) {
|
||||
case 'script':
|
||||
return scriptDraftToWorkspaceItem(entry.path, entry.value as NewScript, isDraft)
|
||||
return scriptDraftToWorkspaceItem(entry.storagePath, entry.value as NewScript, isDraft)
|
||||
case 'flow':
|
||||
return flowDraftToWorkspaceItem(entry.path, entry.value, isDraft)
|
||||
return flowDraftToWorkspaceItem(entry.storagePath, entry.value, isDraft)
|
||||
case 'raw_app':
|
||||
return appDraftToWorkspaceItem(entry.path, entry.value as AppDraftValue, isDraft)
|
||||
return appDraftToWorkspaceItem(entry.storagePath, entry.value as AppDraftValue, isDraft)
|
||||
case 'trigger_schedule':
|
||||
return scheduleDraftToWorkspaceItem(entry.path, entry.value as NewSchedule, isDraft)
|
||||
return scheduleDraftToWorkspaceItem(entry.storagePath, entry.value as NewSchedule, isDraft)
|
||||
case 'resource':
|
||||
return resourceDraftToWorkspaceItem(entry.path, entry.value as CreateResource, isDraft)
|
||||
return resourceDraftToWorkspaceItem(entry.storagePath, entry.value as CreateResource, isDraft)
|
||||
case 'variable':
|
||||
return variableDraftToWorkspaceItem(entry.path, entry.value as CreateVariable, isDraft)
|
||||
return variableDraftToWorkspaceItem(entry.storagePath, entry.value as CreateVariable, isDraft)
|
||||
default:
|
||||
const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind]
|
||||
return triggerKind
|
||||
? triggerDraftToWorkspaceItem(
|
||||
triggerKind,
|
||||
entry.path,
|
||||
entry.storagePath,
|
||||
entry.value as TriggerRequestBody,
|
||||
isDraft
|
||||
)
|
||||
@@ -356,81 +335,73 @@ function sharedDraftEntryToWorkspaceItem(
|
||||
}
|
||||
}
|
||||
|
||||
type SharedDraftLookup = {
|
||||
type LocalWorkspaceItemLookup = {
|
||||
storagePath: string
|
||||
value: unknown
|
||||
source: UserDraftEntrySource
|
||||
draftSource?: UserDraftListEntry['draftSource']
|
||||
item: WorkspaceItem
|
||||
}
|
||||
|
||||
function findSharedDraft(
|
||||
workspace: string,
|
||||
function isDeployableEntry(entry: Pick<LocalWorkspaceItemLookup, 'source' | 'draftSource'>): boolean {
|
||||
return entry.source !== 'live' && entry.draftSource === 'external'
|
||||
}
|
||||
|
||||
function itemHasTarget(
|
||||
item: WorkspaceItem,
|
||||
type: SharedWorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): SharedDraftLookup | undefined {
|
||||
): boolean {
|
||||
if (item.type !== type) return false
|
||||
if (item.path !== path) return false
|
||||
return type !== 'trigger' || item.triggerKind === triggerKind
|
||||
}
|
||||
|
||||
function listSharedEntries(
|
||||
workspace: string,
|
||||
itemKinds: UserDraftItemKind[],
|
||||
isDraft: boolean
|
||||
): LocalWorkspaceItemLookup[] {
|
||||
const entries: LocalWorkspaceItemLookup[] = []
|
||||
for (const entry of UserDraft.list({ workspace, itemKinds })) {
|
||||
const item = sharedDraftEntryToWorkspaceItem(entry, isDraft)
|
||||
if (!item) continue
|
||||
entries.push({
|
||||
storagePath: entry.storagePath,
|
||||
source: entry.source,
|
||||
draftSource: entry.draftSource,
|
||||
item
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function findSharedEntry(
|
||||
workspace: string,
|
||||
type: SharedWorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind: TriggerKind | undefined,
|
||||
mode: 'draft' | 'current'
|
||||
): LocalWorkspaceItemLookup | undefined {
|
||||
const itemKind = sharedDraftKind(type, triggerKind)
|
||||
if (!itemKind) return undefined
|
||||
|
||||
const cachedStoragePath = storagePathByVisibleItem.get(
|
||||
storagePathCacheKey(workspace, type, path, triggerKind)
|
||||
)
|
||||
if (cachedStoragePath !== undefined) {
|
||||
const cached = UserDraft.get(itemKind, cachedStoragePath, { workspace })
|
||||
if (cached !== undefined) return { storagePath: cachedStoragePath, value: cached }
|
||||
forgetStoragePath(workspace, type, path, triggerKind)
|
||||
}
|
||||
|
||||
const direct = UserDraft.get(itemKind, path, { workspace })
|
||||
if (direct !== undefined) return { storagePath: path, value: direct }
|
||||
|
||||
if (path !== '') {
|
||||
const addEditorDraft = UserDraft.get(itemKind, '', { workspace })
|
||||
if (addEditorDraft !== undefined && getItemPath('', addEditorDraft) === path) {
|
||||
return { storagePath: '', value: addEditorDraft }
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of UserDraft.list({ workspace, itemKinds: [itemKind] })) {
|
||||
if (getItemPath(entry.path, entry.value) === path) {
|
||||
return { storagePath: entry.path, value: entry.value }
|
||||
}
|
||||
for (const entry of listSharedEntries(workspace, [itemKind], mode === 'draft')) {
|
||||
if (mode === 'draft' && !isDeployableEntry(entry)) continue
|
||||
if (itemHasTarget(entry.item, type, path, triggerKind)) return entry
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getSharedDraft(
|
||||
function getSharedItem(
|
||||
workspace: string,
|
||||
type: SharedWorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind,
|
||||
isDraft = true
|
||||
mode: 'draft' | 'current' = 'draft'
|
||||
): WorkspaceItem | undefined {
|
||||
const draft = findSharedDraft(workspace, type, path, triggerKind)
|
||||
if (!draft) return undefined
|
||||
|
||||
switch (type) {
|
||||
case 'script':
|
||||
return scriptDraftToWorkspaceItem(draft.storagePath, draft.value as NewScript, isDraft)
|
||||
case 'flow':
|
||||
return flowDraftToWorkspaceItem(draft.storagePath, draft.value, isDraft)
|
||||
case 'app':
|
||||
return appDraftToWorkspaceItem(draft.storagePath, draft.value as AppDraftValue, isDraft)
|
||||
case 'schedule':
|
||||
return scheduleDraftToWorkspaceItem(draft.storagePath, draft.value as NewSchedule, isDraft)
|
||||
case 'trigger':
|
||||
return triggerKind
|
||||
? triggerDraftToWorkspaceItem(
|
||||
triggerKind,
|
||||
draft.storagePath,
|
||||
draft.value as TriggerRequestBody,
|
||||
isDraft
|
||||
)
|
||||
: undefined
|
||||
case 'resource':
|
||||
return resourceDraftToWorkspaceItem(draft.storagePath, draft.value as CreateResource, isDraft)
|
||||
case 'variable':
|
||||
return variableDraftToWorkspaceItem(draft.storagePath, draft.value as CreateVariable, isDraft)
|
||||
}
|
||||
return findSharedEntry(workspace, type, path, triggerKind, mode)?.item
|
||||
}
|
||||
|
||||
function deleteSharedDraft(
|
||||
@@ -441,8 +412,8 @@ function deleteSharedDraft(
|
||||
): void {
|
||||
const itemKind = sharedDraftKind(type, triggerKind)
|
||||
if (!itemKind) return
|
||||
const draft = findSharedDraft(workspace, type, path, triggerKind)
|
||||
UserDraft.remove(itemKind, draft?.storagePath ?? path, { workspace })
|
||||
const draft = findSharedEntry(workspace, type, path, triggerKind, 'current')
|
||||
UserDraft.clear(itemKind, draft?.storagePath ?? path, { workspace })
|
||||
}
|
||||
|
||||
export function getGlobalDraft(
|
||||
@@ -452,7 +423,7 @@ export function getGlobalDraft(
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
if (isSharedWorkspaceItemType(type)) {
|
||||
return getSharedDraft(workspace, type, path, triggerKind)
|
||||
return getSharedItem(workspace, type, path, triggerKind, 'draft')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -469,7 +440,7 @@ export function getGlobalCurrentItem(
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
if (isSharedWorkspaceItemType(type)) {
|
||||
return getSharedDraft(workspace, type, path, triggerKind, false)
|
||||
return getSharedItem(workspace, type, path, triggerKind, 'current')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -481,7 +452,7 @@ export function getGlobalDraftStoragePath(
|
||||
triggerKind?: TriggerKind
|
||||
): string | undefined {
|
||||
if (isSharedWorkspaceItemType(type)) {
|
||||
return findSharedDraft(workspace, type, path, triggerKind)?.storagePath
|
||||
return findSharedEntry(workspace, type, path, triggerKind, 'current')?.storagePath
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -489,10 +460,9 @@ export function getGlobalDraftStoragePath(
|
||||
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
|
||||
const drafts = new Map<string, WorkspaceItem>()
|
||||
|
||||
for (const entry of UserDraft.list({ workspace, itemKinds: [...SHARED_DRAFT_KINDS] })) {
|
||||
const draft = sharedDraftEntryToWorkspaceItem(entry)
|
||||
if (!draft) continue
|
||||
rememberStoragePath(workspace, draft, entry.path)
|
||||
for (const entry of listSharedEntries(workspace, [...SHARED_DRAFT_KINDS], true)) {
|
||||
if (!isDeployableEntry(entry)) continue
|
||||
const { item: draft } = entry
|
||||
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
|
||||
}
|
||||
|
||||
@@ -509,13 +479,11 @@ export function listGlobalCurrentItems(
|
||||
): WorkspaceItem[] {
|
||||
const items = new Map<string, WorkspaceItem>()
|
||||
|
||||
for (const entry of UserDraft.list({
|
||||
for (const { item } of listSharedEntries(
|
||||
workspace,
|
||||
itemKinds: sharedDraftKindsForTypes(types)
|
||||
})) {
|
||||
const item = sharedDraftEntryToWorkspaceItem(entry, false)
|
||||
if (!item) continue
|
||||
rememberStoragePath(workspace, item, entry.path)
|
||||
sharedDraftKindsForTypes(types),
|
||||
false
|
||||
)) {
|
||||
items.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item)
|
||||
}
|
||||
|
||||
@@ -535,7 +503,8 @@ export function deleteGlobalDraft(
|
||||
|
||||
export function clearGlobalDrafts(workspace: string): void {
|
||||
for (const draft of UserDraft.list({ workspace, itemKinds: [...SHARED_DRAFT_KINDS] })) {
|
||||
UserDraft.remove(draft.itemKind, draft.path, { workspace })
|
||||
if (!isDeployableEntry(draft)) continue
|
||||
UserDraft.clear(draft.itemKind, draft.storagePath, { workspace })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface TriggerDraftSync {
|
||||
|
||||
/**
|
||||
* Shared local-autosave wiring for the trigger editors. Holding a live
|
||||
* `UserDraft` handle is what makes an external `UserDraft.save('trigger_x',
|
||||
* `UserDraft` handle is what makes an external `UserDraft.saveExternal('trigger_x',
|
||||
* …)` (another tab, a programmatic write) propagate into the open editor.
|
||||
*
|
||||
* - **apply-effect**: reflects external `handle.draft` changes into the form.
|
||||
|
||||
@@ -47,10 +47,19 @@ export type UserDraftListOptions = UserDraftOptions & {
|
||||
itemKinds?: UserDraftItemKind[]
|
||||
}
|
||||
|
||||
export type UserDraftEntrySource = 'persisted' | 'live' | 'both'
|
||||
|
||||
export type UserDraftListEntry<V = unknown> = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
/**
|
||||
* Storage key path. For new-item editor drafts this can be empty even
|
||||
* when the draft value already contains its final workspace path.
|
||||
*/
|
||||
path: string
|
||||
storagePath: string
|
||||
source: UserDraftEntrySource
|
||||
draftSource?: UserDraftMeta['draftSource']
|
||||
value: V
|
||||
}
|
||||
|
||||
@@ -79,6 +88,7 @@ export type UserDraftSpec<V> = {
|
||||
export type UserDraftMeta = {
|
||||
remoteRev?: string | number
|
||||
remoteDraftRev?: string | number
|
||||
draftSource?: 'external'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +150,7 @@ function wrap<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | u
|
||||
const out: StoredDraft<V> = { value }
|
||||
if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev
|
||||
if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev
|
||||
if (meta?.draftSource !== undefined) out.draftSource = meta.draftSource
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -152,6 +163,7 @@ function extractMeta(stored: StoredDraft<unknown> | undefined): UserDraftMeta {
|
||||
const meta: UserDraftMeta = {}
|
||||
if (stored.remoteRev !== undefined) meta.remoteRev = stored.remoteRev
|
||||
if (stored.remoteDraftRev !== undefined) meta.remoteDraftRev = stored.remoteDraftRev
|
||||
if (stored.draftSource !== undefined) meta.draftSource = stored.draftSource
|
||||
return meta
|
||||
}
|
||||
|
||||
@@ -224,7 +236,15 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone($state.snapshot(value)) as T
|
||||
const snapshot = $state.snapshot(value)
|
||||
try {
|
||||
return structuredClone(snapshot) as T
|
||||
} catch {
|
||||
// Live editor values may contain runtime-only fields (functions,
|
||||
// class instances). Listing should still expose the usable draft
|
||||
// shape; downstream converters copy only the fields they understand.
|
||||
return snapshot as T
|
||||
}
|
||||
}
|
||||
|
||||
function listLocalStorageDrafts(
|
||||
@@ -255,6 +275,9 @@ function listLocalStorageDrafts(
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
storagePath: path,
|
||||
source: 'persisted',
|
||||
draftSource: stored?.draftSource,
|
||||
value: clone(value)
|
||||
})
|
||||
}
|
||||
@@ -327,29 +350,53 @@ export function localDraftDiffers<V>(
|
||||
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
|
||||
}
|
||||
|
||||
function saveDraft<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V,
|
||||
opts: UserDraftOptions | undefined,
|
||||
metaPatch: UserDraftMeta,
|
||||
persistLiveEntry: boolean
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const key = localStorageKey(ws, itemKind, path)
|
||||
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.
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
const meta: UserDraftMeta = { ...extractMeta(current), ...metaPatch }
|
||||
entry.state.val = wrap(value, meta)
|
||||
if (persistLiveEntry) {
|
||||
persistDirect(key, value, meta)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No live handle: preserve any persisted meta so the staleness
|
||||
// signal survives a write while the editor is closed.
|
||||
const existing = readPersisted<unknown>(key)
|
||||
persistDirect(key, value, { ...extractMeta(existing), ...metaPatch })
|
||||
}
|
||||
|
||||
export const UserDraft = {
|
||||
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
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.
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
entry.state.val = wrap(value, extractMeta(current))
|
||||
return
|
||||
}
|
||||
// No live handle: preserve any persisted meta so the staleness
|
||||
// signal survives a write while the editor is closed.
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
try {
|
||||
localStorage.setItem(
|
||||
localStorageKey(ws, itemKind, path),
|
||||
JSON.stringify(stamp(wrap(value, extractMeta(existing))))
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('UserDraft.save: localStorage write failed', e)
|
||||
}
|
||||
saveDraft(itemKind, path, value, opts, {}, false)
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist a draft written by an external actor such as global AI mode.
|
||||
* Unlike editor baseline hydration, this must be visible immediately to
|
||||
* draft deployment/listing tools even when a live editor handle is open.
|
||||
*/
|
||||
saveExternal<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
saveDraft(itemKind, path, value, opts, { draftSource: 'external' }, true)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -407,11 +454,14 @@ export const UserDraft = {
|
||||
if (entry) {
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
if (current === undefined) return
|
||||
entry.state.val = wrap(current.value, meta)
|
||||
entry.state.val = wrap(current.value, { ...extractMeta(current), ...meta })
|
||||
}
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
if (existing === undefined) return
|
||||
persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta)
|
||||
persistDirect(localStorageKey(ws, itemKind, path), existing.value, {
|
||||
...extractMeta(existing),
|
||||
...meta
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -452,10 +502,14 @@ export const UserDraft = {
|
||||
const value = unwrap(entry.state.val as StoredDraft<unknown> | undefined)
|
||||
if (value === undefined) continue
|
||||
|
||||
const persisted = drafts.get(key)
|
||||
drafts.set(key, {
|
||||
workspace: ws,
|
||||
itemKind: entry.itemKind,
|
||||
path: entry.path,
|
||||
storagePath: entry.path,
|
||||
source: persisted ? 'both' : 'live',
|
||||
draftSource: extractMeta(entry.state.val as StoredDraft<unknown> | undefined).draftSource,
|
||||
value: clone(value)
|
||||
})
|
||||
}
|
||||
@@ -500,6 +554,10 @@ export const UserDraft = {
|
||||
}
|
||||
},
|
||||
|
||||
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
UserDraft.discard(itemKind, path, undefined, opts)
|
||||
},
|
||||
|
||||
use<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
@@ -698,9 +756,12 @@ function makeHandle<V>(
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
if (current === undefined) return
|
||||
state.val = wrap(current.value, meta)
|
||||
state.val = wrap(current.value, { ...extractMeta(current), ...meta })
|
||||
if (opts?.force) {
|
||||
persistDirect(localStorageKey(workspace, itemKind, path), current.value, meta)
|
||||
persistDirect(localStorageKey(workspace, itemKind, path), current.value, {
|
||||
...extractMeta(current),
|
||||
...meta
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ function flushPersist(): void {
|
||||
|
||||
// Helper: localStorage payloads are always wrapped as { value: <draft> } so
|
||||
// future metadata fields can be added without breaking existing entries.
|
||||
function wrapped<V>(value: V): string {
|
||||
return JSON.stringify({ value })
|
||||
function wrapped<V>(value: V, meta: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({ value, ...meta })
|
||||
}
|
||||
|
||||
// Helper: read a localStorage entry, strip the GC `lastWrittenAt` stamp so
|
||||
@@ -156,6 +156,19 @@ describe('UserDraft.use() — observer sync', () => {
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9))
|
||||
})
|
||||
|
||||
it('saveExternal() persists through live use() handles and marks the draft source', () => {
|
||||
const handle = UserDraft.use<number>('flow', 'u/me/external')
|
||||
expect(handle.draft).toBeUndefined()
|
||||
|
||||
UserDraft.saveExternal('flow', 'u/me/external', 7)
|
||||
expect(handle.draft).toBe(7)
|
||||
flushPersist()
|
||||
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/external')).toBe(
|
||||
wrapped(7, { draftSource: 'external' })
|
||||
)
|
||||
})
|
||||
|
||||
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))
|
||||
@@ -200,6 +213,18 @@ describe('UserDraft.use() — observer sync', () => {
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/wipe')).toBeNull()
|
||||
})
|
||||
|
||||
it('clear() clears both localStorage and the live handle', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/clear-live', wrapped('local-edit'))
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/clear-live')
|
||||
expect(handle.draft).toBe('local-edit')
|
||||
|
||||
UserDraft.clear('flow', 'u/me/clear-live')
|
||||
flushPersist()
|
||||
|
||||
expect(handle.draft).toBeUndefined()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear-live')).toBeNull()
|
||||
})
|
||||
|
||||
it('the second write through the handle setter persists to localStorage', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/setter')
|
||||
|
||||
@@ -298,17 +323,39 @@ describe('UserDraft.list()', () => {
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
path: 'u/me/script1',
|
||||
storagePath: 'u/me/script1',
|
||||
source: 'persisted',
|
||||
draftSource: undefined,
|
||||
value: { content: 'code' }
|
||||
},
|
||||
{
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'flow',
|
||||
path: 'u/me/flow1',
|
||||
storagePath: 'u/me/flow1',
|
||||
source: 'persisted',
|
||||
draftSource: undefined,
|
||||
value: { modules: [] }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('exposes draftSource for externally saved drafts', () => {
|
||||
UserDraft.saveExternal('script', 'u/me/script1', { content: 'code' })
|
||||
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
||||
{
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
path: 'u/me/script1',
|
||||
storagePath: 'u/me/script1',
|
||||
source: 'persisted',
|
||||
draftSource: 'external',
|
||||
value: { content: 'code' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers live handle state over a stale localStorage entry', () => {
|
||||
const handle = UserDraft.use<{ source: string }>('script', 'u/me/live')
|
||||
handle.draft = { source: 'live' }
|
||||
@@ -319,6 +366,9 @@ describe('UserDraft.list()', () => {
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
path: 'u/me/live',
|
||||
storagePath: 'u/me/live',
|
||||
source: 'both',
|
||||
draftSource: undefined,
|
||||
value: { source: 'live' }
|
||||
}
|
||||
])
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
})
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.save into the form. Idempotent + the
|
||||
// Reflect an external UserDraft.saveExternal into the form. Idempotent + the
|
||||
// d == null guard keeps it from looping with the mirror above or
|
||||
// clobbering "start fresh" loads (which discard the in-memory draft).
|
||||
$effect(() => {
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
draftHandle.draft = { files, runnables, data, summary }
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.save into the form. Idempotent; the
|
||||
// Reflect an external UserDraft.saveExternal into the form. Idempotent; the
|
||||
// `!files` guard skips the reload window so it doesn't fight loadApp.
|
||||
$effect(() => {
|
||||
const d = draftHandle.draft
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
}
|
||||
})
|
||||
|
||||
let drafts = $derived($workspaceStore ? listGlobalDrafts($workspaceStore) : [])
|
||||
let refreshRevision = $state(0)
|
||||
let drafts = $derived.by(() => {
|
||||
refreshRevision
|
||||
return $workspaceStore ? listGlobalDrafts($workspaceStore) : []
|
||||
})
|
||||
|
||||
function draftKey(item: WorkspaceItem): string {
|
||||
return `${item.type}:${item.triggerKind ?? '-'}:${item.path}`
|
||||
@@ -31,11 +35,13 @@
|
||||
function deleteDraft(item: WorkspaceItem) {
|
||||
if (!$workspaceStore) return
|
||||
deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
|
||||
refreshRevision += 1
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!$workspaceStore) return
|
||||
clearGlobalDrafts($workspaceStore)
|
||||
refreshRevision += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -45,7 +51,7 @@
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Global AI drafts</h1>
|
||||
<p class="text-sm text-tertiary">
|
||||
Dev-only inspector for global AI drafts, including shared editor drafts.
|
||||
Dev-only inspector for drafts written by global AI mode.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -85,6 +91,8 @@
|
||||
variant="default"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
iconOnly
|
||||
aria-label={`Delete draft ${draft.path}`}
|
||||
title={`Delete draft ${draft.path}`}
|
||||
onclick={() => deleteDraft(draft)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user