fix: the drawer owns its opening, a loaded script draft keeps no base it lacks, and a legacy occupant says who can clear it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 11:54:01 +02:00
co-authored by Claude Opus 5
parent d3b87cf70b
commit f71262dc7f
10 changed files with 173 additions and 42 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n (SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND (email = $4 OR email IS NULL)\n LIMIT 1) as \"at_target\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email = $4\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n ) as \"poisoned!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email IS NULL) as \"legacy!\" ",
"query": "SELECT\n -- Own row first: with both an own and a legacy row at the destination,\n -- a bare LIMIT 1 would name an arbitrary one and the two need different\n -- remedies (discard your own vs. ask an admin).\n (SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND (email = $4 OR email IS NULL)\n ORDER BY email NULLS LAST LIMIT 1) as \"at_target\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email = $4) as \"at_target_own!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email = $4\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n ) as \"poisoned!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email IS NULL) as \"legacy!\" ",
"describe": {
"columns": [
{
@@ -10,11 +10,16 @@
},
{
"ordinal": 1,
"name": "poisoned!",
"name": "at_target_own!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "poisoned!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "legacy!",
"type_info": "Bool"
}
@@ -64,10 +69,11 @@
]
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "3e3651d23d759a4cb3480afb5d12a05791832d91ca27ab2be3370ad19e46fc1a"
"hash": "07247926f7acb6578cd0edd021e56b0d0cadbc506823f6f2051f8764bf727fb8"
}
+57 -1
View File
@@ -74,6 +74,59 @@ async fn test_rename_onto_a_draft_is_refused(db: Pool<Postgres>) -> anyhow::Resu
Ok(())
}
/// A legacy (ownerless) draft occupies its path too: a deploy there deletes it together
/// with the caller's own row, so a move that parks a second draft beside it would discard
/// edits the caller never saw. Only an admin can clear it, so the refusal says so.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_a_legacy_destination(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/mvtaken_b"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/mvtaken_legacy" }))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "move onto a legacy draft was allowed: {body}");
assert!(
body.contains("legacy workspace draft") && body.contains("workspace admin"),
"the refusal did not point at the one remedy: {body}"
);
// Both rows stayed where they were: the caller's own, and the legacy one the list
// synthesizes under the caller's name.
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
let mut at = list
.iter()
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
.filter(|(_, p)| p.starts_with("u/test-user/mvtaken_b") || p.ends_with("mvtaken_legacy"))
.collect::<Vec<_>>();
at.sort();
assert_eq!(
at,
vec![
("script", "u/test-user/mvtaken_b"),
("script", "u/test-user/mvtaken_legacy")
],
"{list:?}"
);
Ok(())
}
/// A classic app and a raw app deploy into the same table, so a draft-only move onto
/// the other kind's draft must be refused: deploying either path afterwards deletes
/// the caller's drafts of both kinds, taking the loser's item with it.
@@ -93,7 +146,10 @@ async fn test_draft_move_refuses_the_other_app_kind(db: Pool<Postgres>) -> anyho
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "move onto a classic app draft was allowed: {body}");
assert_eq!(
status, 400,
"move onto a classic app draft was allowed: {body}"
);
assert!(
body.contains("already have a draft at 'u/test-user/mvtaken_app' (app)"),
"the refusal did not name the occupying kind: {body}"
+7
View File
@@ -16,6 +16,13 @@ VALUES ('test-workspace', 'u/test-user/mvtaken_b', 'script',
'{"path": "u/test-user/mvtaken_b", "summary": "B", "content": ""}',
'test@windmill.dev');
-- An ownerless (pre per-user drafts) row at a third path: a deploy there deletes it
-- together with the caller's, so it occupies the path just as an own draft does.
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/mvtaken_legacy', 'script',
'{"path": "u/test-user/mvtaken_legacy", "summary": "legacy", "content": ""}',
NULL);
-- A draft-only classic app and a draft-only raw app of the same owner. They share
-- the `app` table, so one occupies the other's path.
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
+17 -2
View File
@@ -773,9 +773,14 @@ async fn move_draft(
if moved.is_none() {
let row = sqlx::query!(
r#"SELECT
-- Own row first: with both an own and a legacy row at the destination,
-- a bare LIMIT 1 would name an arbitrary one and the two need different
-- remedies (discard your own vs. ask an admin).
(SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[]) AND (email = $4 OR email IS NULL)
LIMIT 1) as "at_target",
ORDER BY email NULLS LAST LIMIT 1) as "at_target",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[]) AND email = $4) as "at_target_own!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
AND typ = $2 AND email = $4
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0
@@ -811,7 +816,17 @@ async fn move_draft(
// Naming the kind matters for the app pair: a classic-app draft refusing a
// raw-app move is invisible in the raw-app list the caller is looking at.
let occupant = occupant.replace('_', " ");
format!("You already have a draft at '{new_path}' ({occupant})")
if row.at_target_own {
format!("You already have a draft at '{new_path}' ({occupant})")
} else {
// An ownerless row the caller cannot clear themselves, so send them to
// the one place it can be resolved rather than to "discard your draft".
format!(
"A legacy workspace draft with no owner is already at '{new_path}' \
({occupant}). A workspace admin can claim or discard it on the Review & \
deploy drafts page."
)
}
} else {
format!("You have no draft at '{path}'")
}));
@@ -78,6 +78,26 @@
diffViewer?.closeDrawer()
}
/** Counted per opening, and counted here rather than in the editor that opens one: a
* path change remounts the editor while this drawer stays mounted, so a counter local
* to the editor is one an outlived request still matches — it would open and fill the
* drawer with the item the user just left. Every write an opening makes (the blanking
* `openDrawer` included) checks `ownsOpening` first. */
let openingToken = 0
export function beginOpening(): number {
return ++openingToken
}
export function ownsOpening(token: number): boolean {
return token === openingToken
}
/** Drop the opening in flight: the editor that started it is going away. */
export function abandonOpening() {
openingToken++
}
function prepareDiff(data: Value) {
const metadata = structuredClone(cleanValueProperties(replaceFalseWithUndefined(data)))
const content = metadata['content']
+14 -10
View File
@@ -41,7 +41,7 @@
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { getContext, setContext, untrack } from 'svelte'
import { getContext, onDestroy, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -484,7 +484,7 @@
})
// A superseded opening must not write these: the current one would then render
// and offer Take latest against the older head.
if (opening != null && opening !== diffOpening) return
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
deployedValue = replaceFalseWithUndefined({
...flow,
edited_at: undefined,
@@ -1166,22 +1166,26 @@
}
}
/** Bumped per drawer opening: the fetches below are awaited, so a reopen (or a
* path change) while they run must not have the older one land last. */
let diffOpening = 0
// An opening outlives this editor when a path change remounts it mid-fetch; without
// this it would still open the drawer on the item the user left.
onDestroy(() => diffDrawer?.abandonOpening())
export async function openDiffDrawer() {
const opening = ++diffOpening
if (!savedFlow) return
// The fetches below are awaited, so a reopen (or a path change, which remounts
// this editor but not the drawer) while they run must not have the older one
// land last. The drawer counts the openings for that reason.
const opening = diffDrawer?.beginOpening()
if (opening == null) return
await syncWithDeployed(opening)
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
// Blanking the drawer belongs to the opening that will fill it.
if (opening !== diffOpening) return
diffDrawer?.openDrawer()
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.openDrawer()
const currentFlow = flowStore.val
const versions = await deployedVersionOptions()
if (opening !== diffOpening) return
diffDrawer?.setDiff({
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedFlow,
deployedLabel,
@@ -87,7 +87,7 @@
import { writable } from 'svelte/store'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { getContext, onMount, setContext, tick, untrack } from 'svelte'
import { getContext, onDestroy, onMount, setContext, tick, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
import Badge from './common/badge/Badge.svelte'
@@ -629,7 +629,7 @@
// A superseded opening must not write these: the current one would then render
// and offer Take latest against the older head.
if (opening != null && opening !== diffOpening) return
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
deployedValue = replaceFalseWithUndefined({
...latestScript,
workspace_id: undefined,
@@ -873,15 +873,19 @@
}
}
/** Bumped per drawer opening: the fetches below are awaited, so a reopen (or a
* path change) while they run must not have the older one land last. */
let diffOpening = 0
// An opening outlives this editor when a path change remounts it mid-fetch; without
// this it would still open the drawer on the item the user left.
onDestroy(() => diffDrawer?.abandonOpening())
export async function openDiffDrawer() {
const opening = ++diffOpening
if (!savedScript) {
return
}
// The fetches below are awaited, so a reopen (or a path change, which remounts
// this editor but not the drawer) while they run must not have the older one
// land last. The drawer counts the openings for that reason.
const opening = diffDrawer?.beginOpening()
if (opening == null) return
await syncWithDeployed(opening)
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
@@ -900,12 +904,12 @@
if (current.assets && !current.assets.length) delete current.assets
// Blanking the drawer belongs to the opening that will fill it.
if (opening !== diffOpening) return
diffDrawer?.openDrawer()
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.openDrawer()
const headHash = (deployed as { hash?: string } | undefined)?.hash
const versions = await deployedVersionOptions(headHash)
if (opening !== diffOpening) return
diffDrawer?.setDiff({
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.setDiff({
mode: 'normal',
deployed,
deployedLabel: deployedVersionLabel(deployed),
@@ -56,4 +56,11 @@ export interface DiffDrawerI {
openDrawer: () => void
closeDrawer: () => void
setDiff: (diff: DiffDrawerDiff) => void
/** Claim the drawer for one opening. Filling it takes awaited fetches, and a path
* change remounts the editor while this drawer stays, so the token lives here: an
* editor checks `ownsOpening` before every write and drops the opening it started
* when it goes away. */
beginOpening: () => number
ownsOpening: (token: number) => boolean
abandonOpening: () => void
}
@@ -28,7 +28,7 @@
Undo,
WandSparkles
} from 'lucide-svelte'
import { untrack } from 'svelte'
import { onDestroy, untrack } from 'svelte'
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils'
import { random_adj } from '$lib/components/random_positive_adjetive'
@@ -406,7 +406,7 @@
// A superseded opening must not write these: the current one would then render
// and offer Take latest against the older head.
if (opening != null && opening !== diffOpening) return
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
deployedBy = deployedApp.created_by
const shownVersions = (deployedApp as { versions?: number[] }).versions
deployedVersionShown = Array.isArray(shownVersions)
@@ -453,25 +453,29 @@
}
}
/** Bumped per drawer opening: the fetches below are awaited, so a reopen (or a
* path change) while they run must not have the older one land last. */
let diffOpening = 0
// An opening outlives this editor when a path change remounts it mid-fetch; without
// this it would still open the drawer on the item the user left.
onDestroy(() => diffDrawer?.abandonOpening())
export async function openDiffDrawer() {
const opening = ++diffOpening
if (!savedApp) {
return
}
// The fetches below are awaited, so a reopen (or a path change, which remounts
// this editor but not the drawer) while they run must not have the older one
// land last. The drawer counts the openings for that reason.
const opening = diffDrawer?.beginOpening()
if (opening == null) return
// deployedValue should be syncronized when we open Diff
await syncWithDeployed(opening)
// Blanking the drawer belongs to the opening that will fill it.
if (opening !== diffOpening) return
diffDrawer?.openDrawer()
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.openDrawer()
const versions = await deployedVersionOptions()
if (opening !== diffOpening) return
diffDrawer?.setDiff({
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.setDiff({
mode: 'normal',
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
versions,
@@ -692,12 +696,16 @@
if (!savedApp || newApp) {
return
}
// The other entry point into the same drawer, so it takes an opening too.
const opening = diffDrawer?.beginOpening()
if (opening == null) return
// deployedValue should be syncronized when we open Diff
await syncWithDeployed()
await syncWithDeployed(opening)
if (!diffDrawer?.ownsOpening(opening)) return
saveDrawerOpen = false
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
diffDrawer.openDrawer()
diffDrawer.setDiff({
mode: 'normal',
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
current: currentDiffValue,
@@ -384,15 +384,19 @@
OtherUserDraftLoad.clear($workspaceStore!, 'script', draftPath)
}
if (pendingLoad) {
const theirs = (pendingLoad.value as { parent_hash?: string })?.parent_hash
const loadedValue = {
...deployedScript,
...(pendingLoad.value as object),
parent_hash: (pendingLoad.value as { parent_hash?: string })?.parent_hash ?? parentHash
parent_hash: theirs
} as EditableScript
// Their draft's base, not ours: the prompt and the deploy guard read these,
// and deploying their content on our base would claim a version it never
// forked from.
draftBaseHash = loadedValue.parent_hash
// forked from. A draft that has none keeps none — seeding `parentHash`
// (the head, when we have no draft here) would mark their older content as
// forked from the current deploy and silence the stale prompt.
if (theirs == null) delete (loadedValue as { parent_hash?: string }).parent_hash
draftBaseHash = theirs
if (hasOwnDraft) {
OtherUserDraftLoad.beginOverlay({
workspace: $workspaceStore!,