fix: the cross-kind refusal reads properly, and a session flow keeps its own response's head

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 20:10:50 +02:00
co-authored by Claude Opus 5
parent dfbc696ddb
commit de5769fb56
4 changed files with 35 additions and 10 deletions
+17 -1
View File
@@ -215,7 +215,23 @@ async fn test_draft_move_refuses_another_users_other_app_kind(
"a classic app was moved onto another user's raw app: {body}"
);
assert!(
body.contains("Another user has a raw app draft"),
body.contains("holds another user's raw app draft"),
"the refusal did not name the occupant: {body}"
);
// And the other direction, where the occupant reads as the classic kind.
let (status, body) = move_to(
"raw_app",
"u/test-user/mvtaken_raw",
"u/test-user/mvtaken_app_theirs",
)
.await?;
assert_eq!(
status, 400,
"a raw app was moved onto another user's classic app: {body}"
);
assert!(
body.contains("holds another user's app draft"),
"the refusal did not name the occupant: {body}"
);
+3 -1
View File
@@ -32,4 +32,6 @@ INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
('test-workspace', 'u/test-user/mvtaken_raw', 'raw_app',
'{"summary": "raw", "files": {}}', 'test@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_theirs', 'raw_app',
'{"summary": "theirs", "files": {}}', 'test2@windmill.dev');
'{"summary": "theirs", "files": {}}', 'test2@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_app_theirs', 'app',
'{"summary": "theirs", "value": {}}', 'test2@windmill.dev');
+2 -2
View File
@@ -859,8 +859,8 @@ async fn move_draft(
// The other app kind, owned by someone else: one deployed path cannot hold
// both, so this is the other item's path, not a teammate's copy of this one.
format!(
"Another user has a {occupant} draft at '{new_path}', and an app and a \
raw app cannot share a path."
"'{new_path}' holds another user's {occupant} draft, and an app and a raw \
app cannot share a path."
)
}
} else {
@@ -593,8 +593,9 @@ function createRuntime(session: Session): SessionRuntime {
// when the path has never been deployed.
const aiDraft = UserDraft.get<Flow>('flow', path, { workspace })
// getDraft=true omits version_id (the plain getFlowByPath has it) —
// stamp it on so the flow doesn't always diff. Best-effort.
// Fallback for the head: the payload fetches below carry `version_id`, and
// this one covers a response that does not. Best-effort, and a request of
// its own, so the same response wins wherever both are available.
let deployedVersionId: number | undefined
try {
deployedVersionId = (await FlowService.getFlowByPath({ workspace, path }))?.version_id
@@ -608,9 +609,12 @@ function createRuntime(session: Session): SessionRuntime {
// yet on the backend — draft-only flows are a valid state.
try {
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
// `getDraft` omits `version_id`; the editor's deploy guard compares
// against it, so put the head fetched above back on the baseline.
saved.val = { ...(result as SavedFlow), version_id: deployedVersionId }
// The editor's deploy guard compares against the head, so keep this
// response's own and fall back to the one fetched above.
saved.val = {
...(result as SavedFlow),
version_id: (result as SavedFlow).version_id ?? deployedVersionId
}
} catch {
saved.val = undefined
}
@@ -622,7 +626,10 @@ function createRuntime(session: Session): SessionRuntime {
// No local draft yet — seed from `result.draft ?? result`.
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
saved.val = { ...(result as SavedFlow), version_id: deployedVersionId }
saved.val = {
...(result as SavedFlow),
version_id: (result as SavedFlow).version_id ?? deployedVersionId
}
const serverDraft = (result as SavedFlow).draft as Flow | undefined
const flow: Flow = (serverDraft ?? (result as Flow)) as Flow
// Seed the per-tab last_sync from the server draft's timestamp so the