mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 16:02:59 +00:00
fix(drafts): finish settling a write before anything reads it, and follow late landings
Timing the fan-out against the settle was the wrong direction. The settle now sends its own delete and resolves when it has gone out, so the editors report — and the schedule host remounts — on a cell that is already resolved: a stalled delete used to let that remount load the draft the save had just replaced, and restore it over the deployed value. A write can still land after all of that, when an edit made during the save is kept. A list frame reads its rows from a store of its own and nothing reloads it again, so it now reloads on the write landing rather than on our guess about when it will. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
co-authored by
Claude Opus 5
parent
08386433aa
commit
5b99cce175
@@ -392,7 +392,9 @@
|
||||
written.push({ ws, path: s.path })
|
||||
// `s.path`, not the reported one: each workspace settles against the path
|
||||
// its own write used.
|
||||
settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, { workspace: ws })
|
||||
await settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, {
|
||||
workspace: ws
|
||||
})
|
||||
// Path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths(ws)
|
||||
|
||||
@@ -343,7 +343,7 @@
|
||||
// to it via `discard` (not `remove` — blanking the cell to `undefined` reads
|
||||
// as dirty) and keeps an edit made mid-request. Each workspace settles
|
||||
// against the path its own write used, not the reported one.
|
||||
settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, {
|
||||
await settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, {
|
||||
workspace: ws
|
||||
})
|
||||
// Reported after settling, so what hears about the write sees a cell that
|
||||
|
||||
@@ -647,7 +647,9 @@
|
||||
// next save updates or creates, and whether a discard removes the item.
|
||||
edit = true
|
||||
draftOnly = false
|
||||
settleDraftAfterWrite(
|
||||
// Awaited before the host hears: it remounts on the report, and a remount
|
||||
// that overtook this would load the draft this is removing.
|
||||
await settleDraftAfterWrite(
|
||||
'trigger_schedule',
|
||||
scheduleCfg,
|
||||
getScheduleCfg(),
|
||||
@@ -780,7 +782,7 @@
|
||||
// It matches the new one, so nothing would ever show it again — but it is
|
||||
// still a row on the server and a `*` on the list until it is settled away.
|
||||
if (initialConfig)
|
||||
settleDraftAfterWrite(
|
||||
await settleDraftAfterWrite(
|
||||
'trigger_schedule',
|
||||
$state.snapshot(initialConfig),
|
||||
getScheduleCfg(),
|
||||
|
||||
@@ -277,16 +277,20 @@ export function draftValuesEqual(a: unknown, b: unknown): boolean {
|
||||
* only its own default), so the cell is reset rather than left orphaned under a
|
||||
* path the item no longer occupies.
|
||||
*/
|
||||
export function settleDraftAfterWrite<V>(
|
||||
export async function settleDraftAfterWrite<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
written: V,
|
||||
live: V | undefined,
|
||||
fromPath: string,
|
||||
savedPath: string,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (savedPath !== fromPath || draftValuesEqual(live, written)) {
|
||||
UserDraft.discard(itemKind, fromPath, written, opts)
|
||||
// Sent before this resolves, not left on the keystroke debounce: callers
|
||||
// report the write and remount on it, and both read a cell this has to have
|
||||
// finished resolving — including through a frame with a draft store of its own.
|
||||
await flushDraftWrites(itemKind, fromPath, opts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@
|
||||
type WorkspaceItemKind
|
||||
} from '$lib/components/workspacePicker'
|
||||
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
|
||||
import { UserDraft, flushDraftWrites } from '$lib/userDraft.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
|
||||
const globalEnabled = isGlobalAiEnabled()
|
||||
|
||||
@@ -614,7 +615,7 @@
|
||||
// A hosted entity editor wrote its item. Every tab on it hears, across warm
|
||||
// sessions: they share the one draft cell and the one server row, so one left
|
||||
// behind edits a path that is gone or discards over a value already deployed.
|
||||
async function entityWritten(ev: {
|
||||
function entityWritten(ev: {
|
||||
kind: EntityEditorKind
|
||||
path: string
|
||||
workspace: string
|
||||
@@ -625,11 +626,6 @@
|
||||
}) {
|
||||
const page = entityListPage(ev.kind)?.path
|
||||
if (!page) return
|
||||
// The settle that follows a write parks its draft delete on the autosave
|
||||
// debounce, and a list frame reads its `*` markers from a store of its own
|
||||
// — read before that lands, it keeps the row's marker until something else
|
||||
// reloads it, and nothing does.
|
||||
await flushDraftWrites(ev.kind, ev.path, { workspace: ev.workspace })
|
||||
for (const s of warmSessions) {
|
||||
const owner = getRuntime(s.id)?.previewTabs
|
||||
if (!owner || getEffectiveWorkspaceId(s) !== ev.workspace) continue
|
||||
@@ -665,6 +661,43 @@
|
||||
pendingMutations = []
|
||||
reloadTabs(pages, mutations)
|
||||
}
|
||||
// A list frame reads its rows and `*` markers from a store inside the iframe, so
|
||||
// nothing we do afterwards corrects what it already read. Rather than time our
|
||||
// reloads against writes, reload when one lands: a draft written after a save —
|
||||
// an edit made while it was in flight — reaches the list this way too.
|
||||
let draftLandedHandle: ReturnType<typeof setTimeout> | undefined
|
||||
let draftLandedPages = new Set<string>()
|
||||
$effect(() => {
|
||||
const stop = UserDraftDbSyncer.onAnySaved(({ workspace, itemKind }) => {
|
||||
const page = entityListPage(itemKind as EntityEditorKind)?.path
|
||||
if (!page) return
|
||||
draftLandedPages.add(`${workspace}\u0000${page}`)
|
||||
clearTimeout(draftLandedHandle)
|
||||
// Coalesced: a deploy lands several writes for one item back to back.
|
||||
draftLandedHandle = setTimeout(() => {
|
||||
const pages = draftLandedPages
|
||||
draftLandedPages = new Set()
|
||||
for (const s of warmSessions) {
|
||||
const ws = getEffectiveWorkspaceId(s)
|
||||
const owner = getRuntime(s.id)?.previewTabs
|
||||
if (!ws || !owner) continue
|
||||
for (const tab of owner.tabs) {
|
||||
const loc = whereIs(tab)
|
||||
if (parseEntityEditorRoute(loc)) continue
|
||||
if (!pages.has(`${ws}\u0000${stripBase(loc)}`)) continue
|
||||
const key = tabKey(s.id, tab.id)
|
||||
if (mountedTabKeys.has(key)) tabHosts[key]?.reload()
|
||||
}
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
return () => {
|
||||
clearTimeout(draftLandedHandle)
|
||||
draftLandedPages = new Set()
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
// Debounced so a burst of writes (the AI editing several files) reloads once.
|
||||
setToolCompletionListener((name, args, workspace) => {
|
||||
|
||||
Reference in New Issue
Block a user