From bdb111c23658ddaefe94be121d1ff1762117a7ef Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 7 Oct 2026 07:27:58 -0700 Subject: [PATCH] feat: move a live ai session into a workspace fork with its drafts Co-Authored-By: Claude Opus 5.5 (1M context) --- ...bdd4c20cd50574a47f3d76639cecd7aff67c4.json | 66 ++++++ ...a35f985f57fc444a5a88c2e9a2b95f990b9a4.json | 23 +++ ...dbf8e6fe2d383bf45037fea79a2c6664178b2.json | 65 ++++++ ...f22f5ebe6c7568c0afaf2bd5d1a13717d518b.json | 23 +++ ...43374689ef711a282e9d8c7e059d539c6f3a7.json | 65 ++++++ ...9fdb239ef507e23003db03aff7d35b02c540b.json | 16 -- .../tests/drafts.rs | 97 +++++++++ .../windmill-api-workspaces/src/workspaces.rs | 51 +---- backend/windmill-api/openapi.yaml | 56 +++++ backend/windmill-api/src/drafts.rs | 145 +++++++++++++ backend/windmill-common/src/user_drafts.rs | 71 +++++++ frontend/src/lib/components/Editor.svelte | 7 + .../copilot/chat/AIChatManager.svelte.ts | 29 ++- .../copilot/chat/HistoryManager.svelte.ts | 23 ++- .../lib/components/editorKeystrokeFlush.ts | 13 ++ .../sessions/SessionMoveModal.svelte | 80 ++++++++ .../components/sessions/SessionWrapper.svelte | 193 ++++++++++++++++-- .../sessions/WorkspaceFamilyPicker.svelte | 35 +++- .../sessions/moveSessionToFork.test.ts | 80 ++++++++ .../components/sessions/moveSessionToFork.ts | 105 ++++++++++ .../sessions/useUserDraftSync.svelte.ts | 15 ++ frontend/src/lib/userDraftDbSyncer.svelte.ts | 18 ++ 22 files changed, 1188 insertions(+), 88 deletions(-) create mode 100644 backend/.sqlx/query-16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4.json create mode 100644 backend/.sqlx/query-4a9430cb7d2a497f3aa648b020aa35f985f57fc444a5a88c2e9a2b95f990b9a4.json create mode 100644 backend/.sqlx/query-58a1ac3bd426accc18536cdb400dbf8e6fe2d383bf45037fea79a2c6664178b2.json create mode 100644 backend/.sqlx/query-6b3f5fc6d31dd4557a4ac7595ccf22f5ebe6c7568c0afaf2bd5d1a13717d518b.json create mode 100644 backend/.sqlx/query-986c34ebe12210fa1050503f58043374689ef711a282e9d8c7e059d539c6f3a7.json delete mode 100644 backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json create mode 100644 frontend/src/lib/components/editorKeystrokeFlush.ts create mode 100644 frontend/src/lib/components/sessions/SessionMoveModal.svelte create mode 100644 frontend/src/lib/components/sessions/moveSessionToFork.test.ts create mode 100644 frontend/src/lib/components/sessions/moveSessionToFork.ts diff --git a/backend/.sqlx/query-16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4.json b/backend/.sqlx/query-16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4.json new file mode 100644 index 0000000000..2c4202f044 --- /dev/null +++ b/backend/.sqlx/query-16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1\n AND CASE WHEN $4::text[] IS NULL THEN d.email = $3 OR d.email IS NULL\n ELSE d.email = $3\n AND (d.typ::text, d.path) IN (SELECT * FROM unnest($4::text[], $5::text[]))\n END\n ) s\n RETURNING typ AS \"typ: UserDraftItemKind\", path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "typ: UserDraftItemKind", + "type_info": { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline", + "trigger_amqp" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4" +} diff --git a/backend/.sqlx/query-4a9430cb7d2a497f3aa648b020aa35f985f57fc444a5a88c2e9a2b95f990b9a4.json b/backend/.sqlx/query-4a9430cb7d2a497f3aa648b020aa35f985f57fc444a5a88c2e9a2b95f990b9a4.json new file mode 100644 index 0000000000..9ecd1a96fb --- /dev/null +++ b/backend/.sqlx/query-4a9430cb7d2a497f3aa648b020aa35f985f57fc444a5a88c2e9a2b95f990b9a4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1 AND parent_workspace_id = $2 AND deleted = false)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4a9430cb7d2a497f3aa648b020aa35f985f57fc444a5a88c2e9a2b95f990b9a4" +} diff --git a/backend/.sqlx/query-58a1ac3bd426accc18536cdb400dbf8e6fe2d383bf45037fea79a2c6664178b2.json b/backend/.sqlx/query-58a1ac3bd426accc18536cdb400dbf8e6fe2d383bf45037fea79a2c6664178b2.json new file mode 100644 index 0000000000..ec000054d4 --- /dev/null +++ b/backend/.sqlx/query-58a1ac3bd426accc18536cdb400dbf8e6fe2d383bf45037fea79a2c6664178b2.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT typ AS \"typ: UserDraftItemKind\", path FROM draft\n WHERE workspace_id = $1 AND email = $2\n AND (typ::text, path) IN (SELECT * FROM unnest($3::text[], $4::text[]))\n FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "typ: UserDraftItemKind", + "type_info": { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline", + "trigger_amqp" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "58a1ac3bd426accc18536cdb400dbf8e6fe2d383bf45037fea79a2c6664178b2" +} diff --git a/backend/.sqlx/query-6b3f5fc6d31dd4557a4ac7595ccf22f5ebe6c7568c0afaf2bd5d1a13717d518b.json b/backend/.sqlx/query-6b3f5fc6d31dd4557a4ac7595ccf22f5ebe6c7568c0afaf2bd5d1a13717d518b.json new file mode 100644 index 0000000000..42923e6deb --- /dev/null +++ b/backend/.sqlx/query-6b3f5fc6d31dd4557a4ac7595ccf22f5ebe6c7568c0afaf2bd5d1a13717d518b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6b3f5fc6d31dd4557a4ac7595ccf22f5ebe6c7568c0afaf2bd5d1a13717d518b" +} diff --git a/backend/.sqlx/query-986c34ebe12210fa1050503f58043374689ef711a282e9d8c7e059d539c6f3a7.json b/backend/.sqlx/query-986c34ebe12210fa1050503f58043374689ef711a282e9d8c7e059d539c6f3a7.json new file mode 100644 index 0000000000..5047f88f9e --- /dev/null +++ b/backend/.sqlx/query-986c34ebe12210fa1050503f58043374689ef711a282e9d8c7e059d539c6f3a7.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1 AND email = $2\n AND (typ::text, path) IN (SELECT * FROM unnest($3::text[], $4::text[]))\n RETURNING typ AS \"typ: UserDraftItemKind\", path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "typ: UserDraftItemKind", + "type_info": { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline", + "trigger_amqp" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "986c34ebe12210fa1050503f58043374689ef711a282e9d8c7e059d539c6f3a7" +} diff --git a/backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json b/backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json deleted file mode 100644 index f218653d46..0000000000 --- a/backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)\n ) s", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b" -} diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index f45ffb3f8e..9c82887d20 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -295,3 +295,100 @@ async fn test_cross_user_draft_privacy(db: Pool) -> anyhow::Result<()> Ok(()) } + +/// Moving a session's drafts into a fork is all-or-nothing: a draft the caller already has in +/// the fork blocks the whole transfer rather than one side silently winning, and the source +/// copy is removed only together with a successful copy. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_transfer_drafts_to_fork(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let c = client_for("SECRET_TOKEN"); + let me = "test@windmill.dev"; + let fork = "wm-fork-transfer"; + let transfer_url = format!("http://localhost:{port}/api/w/{WS}/drafts/transfer"); + let fork_count = |path: &'static str| { + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM draft WHERE workspace_id = $1 AND path = $2 AND email = $3", + ) + .bind(fork) + .bind(path) + .bind(me) + .fetch_one(&db) + }; + + let r = c + .post(save_url(port, "script", "u/test-user/a")) + .json(&json!({ "value": { "content": "a" } })) + .send() + .await?; + assert_eq!(r.status(), 200); + let r = c + .post(format!( + "http://localhost:{port}/api/w/{WS}/workspaces/create_fork" + )) + .json(&json!({ "id": fork, "name": "transfer" })) + .send() + .await?; + assert!(r.status().is_success(), "create fork: {}", r.text().await?); + // Created after the fork, so only the source has it. + let r = c + .post(save_url(port, "script", "u/test-user/b")) + .json(&json!({ "value": { "content": "b" } })) + .send() + .await?; + assert_eq!(r.status(), 200); + + let items = json!([ + { "kind": "script", "path": "u/test-user/a" }, + { "kind": "script", "path": "u/test-user/b" } + ]); + + // `a` was cloned into the fork: the whole transfer is refused and nothing moves. + let body: serde_json::Value = c + .post(&transfer_url) + .json(&json!({ "target_workspace": fork, "items": items, "remove_from_source": true })) + .send() + .await? + .json() + .await?; + assert_eq!( + body["conflicts"], + json!([{ "kind": "script", "path": "u/test-user/a" }]) + ); + assert_eq!(fork_count("u/test-user/b").await?, 0); + assert_eq!(draft_count(&db, "u/test-user/a", "script", me).await, 1); + assert_eq!(draft_count(&db, "u/test-user/b", "script", me).await, 1); + + sqlx::query("DELETE FROM draft WHERE workspace_id = $1 AND path = 'u/test-user/a'") + .bind(fork) + .execute(&db) + .await?; + let body: serde_json::Value = c + .post(&transfer_url) + .json(&json!({ "target_workspace": fork, "items": items, "remove_from_source": true })) + .send() + .await? + .json() + .await?; + assert_eq!(body["conflicts"], json!([])); + assert_eq!(body["copied"].as_array().unwrap().len(), 2); + assert_eq!(body["removed"].as_array().unwrap().len(), 2); + assert_eq!(fork_count("u/test-user/a").await?, 1); + assert_eq!(fork_count("u/test-user/b").await?, 1); + assert_eq!(draft_count(&db, "u/test-user/a", "script", me).await, 0); + assert_eq!(draft_count(&db, "u/test-user/b", "script", me).await, 0); + + // Only a fork of the source is a valid target. + let r = c + .post(format!( + "http://localhost:{port}/api/w/{fork}/drafts/transfer" + )) + .json(&json!({ "target_workspace": WS, "items": items })) + .send() + .await?; + assert_eq!(r.status(), 400); + + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index ac838d772b..40e50932c0 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -8438,62 +8438,23 @@ async fn clone_raw_apps( Ok(()) } -/// Clone every per-user draft (and the legacy NULL-email workspace draft, -/// if present) from the parent. The fork target is empty at create time so -/// a plain INSERT is safe — no need to UPSERT against the partial unique -/// indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). `id` is the -/// BIGSERIAL synthetic PK and is regenerated by the default; we don't list -/// it in the column set. `created_at` is preserved so the per-tab -/// `last_sync` baseline the editor reads (`?get_draft=true` → overlay's -/// `draft_saved_at`) lines up with the parent's timeline — otherwise the -/// fork's first POST from any open editor would race a stale `last_sync` -/// and trip the conflict modal on every cloned draft. // Only `email = authed_email` and the legacy NULL row are cloned — see -// `clone_workspace_data` for the rationale. +// `clone_workspace_data` for the rationale. The fork is empty at create time, which is what +// makes the plain INSERT of `copy_drafts_to_workspace` safe here. async fn clone_drafts( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, authed_email: &str, ) -> Result<()> { - // A script/flow draft carries the principal in its value, and deploying it in the clone - // would send a pair naming somebody who is not a member there. Stripped rather than - // filtered like `clone_scripts`: the address the draft still carries re-derives the - // clone's own principal at deploy time, which is the more accurate answer of the two. - sqlx::query!( - // A script hash is content-addressed and copied as-is, so a script draft's base - // still names a version the clone has. `clone_flows` / `clone_apps` mint new ids, - // so those drafts arrive with no base (staleness falls back to the timestamps), - // lineage field included, or the next autosave would re-derive the source id. - // - // `clean` is `strip_json_nul`'s parity rule in SQL, so a pre-sanitizer U+0000 - // escape cannot abort the clone on `to_jsonb` or arrive with its principal - // unstripped: escaped backslashes park on chr(1) (lossless, a `json` value's text - // cannot hold a raw control byte) so only a real NUL is removed, and chr(92) - // spells the backslash so no escape sequence reaches this source file. - r#"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base) - SELECT $2, path, typ, - to_json( - CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END - - CASE WHEN typ = 'flow' THEN 'version_id' - WHEN typ IN ('app', 'raw_app') THEN 'parent_version' - ELSE '' END - ), - created_at, email, - CASE WHEN typ = 'script' THEN base END - FROM ( - SELECT d.path, d.typ, d.created_at, d.email, d.base, - replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean - FROM draft d - WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL) - ) s"#, + windmill_common::user_drafts::copy_drafts_to_workspace( + &mut **tx, source_workspace_id, target_workspace_id, authed_email, + None, ) - .execute(&mut **tx) .await?; - Ok(()) } @@ -13263,7 +13224,7 @@ async fn seed_full_diff_scan( /// If the user is not a member of `workspace_id`, returns an authed with no /// folders/groups/operator/admin (except for superadmins, who stay admin) — /// i.e. they will only see what RLS explicitly allows for unknown users. -async fn load_workspace_authed( +pub async fn load_workspace_authed( db: &DB, base_authed: &ApiAuthed, workspace_id: &str, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7e7636a3fd..d02c264ba6 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10945,6 +10945,53 @@ paths: schema: type: string + /w/{workspace}/drafts/transfer: + post: + summary: copy the current user's drafts into a fork and/or remove them here + description: In one transaction, copies the authed user's own drafts of `items` into `target_workspace` (a direct fork of this workspace) and, with `remove_from_source`, deletes them here. Without `target_workspace` it only removes. If the user already has a draft of any item in the target, nothing is written and the items are returned in `conflicts`. + operationId: transferDrafts + tags: + - draft + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + target_workspace: + type: string + items: + type: array + items: + $ref: "#/components/schemas/DraftItemRef" + remove_from_source: + type: boolean + required: [items] + responses: + "200": + description: transfer result + content: + application/json: + schema: + type: object + properties: + copied: + type: array + items: + $ref: "#/components/schemas/DraftItemRef" + removed: + type: array + items: + $ref: "#/components/schemas/DraftItemRef" + conflicts: + type: array + items: + $ref: "#/components/schemas/DraftItemRef" + required: [copied, removed, conflicts] + /w/{workspace}/drafts/migrate_legacy/{kind}/{path}: post: summary: resolve a legacy (workspace-level) draft (admin only) @@ -28424,6 +28471,15 @@ components: surfaced in the fork modal as "Last updated". required: [draft_saved_at] required: [is_draft] + DraftItemRef: + type: object + properties: + kind: + $ref: "#/components/schemas/UserDraftItemKind" + path: + type: string + required: [kind, path] + UserDraftItemKind: type: string description: | diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index a3cd34f3f8..375c6102be 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -34,6 +34,7 @@ pub fn workspaced_service() -> Router { .route("/update/{kind}/{*path}", post(update_draft)) .route("/move/{kind}/{*path}", post(move_draft)) .route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft)) + .route("/transfer", post(transfer_drafts)) } #[derive(Serialize, sqlx::FromRow)] @@ -925,6 +926,150 @@ async fn move_draft( Ok(format!("moved draft {path} to {new_path}")) } +#[derive(Deserialize, Serialize, Clone)] +pub struct DraftItemRef { + pub kind: UserDraftItemKind, + pub path: String, +} + +#[derive(Deserialize)] +pub struct TransferDraftsRequest { + /// A direct fork of this workspace to copy the drafts into. Omitted, the call only removes + /// them here (`remove_from_source`). + #[serde(default)] + pub target_workspace: Option, + pub items: Vec, + #[serde(default)] + pub remove_from_source: bool, +} + +#[derive(Serialize)] +pub struct TransferDraftsResponse { + pub copied: Vec, + pub removed: Vec, + /// Items the caller already has a draft of in the target. When non-empty nothing was + /// written: the caller resolves them first rather than have one side silently win. + pub conflicts: Vec, +} + +/// Copy the caller's own drafts of `items` into a fork of this workspace and/or remove them +/// here, in one transaction, so a session moving to a fork never leaves its work in both +/// places or in neither. +/// +/// Only the caller's rows are touched (never a teammate's or the legacy NULL-email one), so +/// removing needs no write right here, as with discarding one's own draft. Copying needs it +/// in the target. +async fn transfer_drafts( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> Result> { + let items: Vec<(UserDraftItemKind, String)> = + req.items.iter().map(|i| (i.kind, i.path.clone())).collect(); + let kinds: Vec<&str> = items.iter().map(|(k, _)| k.as_str()).collect(); + let paths: Vec<&str> = items.iter().map(|(_, p)| p.as_str()).collect(); + let to_refs = |rows: Vec<(UserDraftItemKind, String)>| -> Vec { + rows.into_iter() + .map(|(kind, path)| DraftItemRef { kind, path }) + .collect() + }; + + if let Some(target) = req.target_workspace.as_deref() { + let is_fork = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1 AND parent_workspace_id = $2 AND deleted = false)", + target, + &w_id, + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !is_fork { + return Err(Error::BadRequest(format!( + "{target} is not a fork of {w_id}" + ))); + } + // `authed` speaks for this workspace only; the copy writes into the fork, so its + // rights there are loaded separately. A non-member gets an empty authed, not an error. + let target_authed = + windmill_api_workspaces::workspaces::load_workspace_authed(&db, &authed, target) + .await?; + let is_member = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false)", + target, + &authed.email, + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !is_member && !target_authed.is_admin { + return Err(Error::PermissionDenied(format!("not a member of {target}"))); + } + for (kind, path) in &items { + require_can_write_path(&target_authed, &db, &user_db, target, *kind, path).await?; + } + } + + let mut tx = db.begin().await?; + let mut copied = vec![]; + if let Some(target) = req.target_workspace.as_deref() { + let conflicts = sqlx::query!( + r#"SELECT typ AS "typ: UserDraftItemKind", path FROM draft + WHERE workspace_id = $1 AND email = $2 + AND (typ::text, path) IN (SELECT * FROM unnest($3::text[], $4::text[])) + FOR UPDATE"#, + target, + &authed.email, + &kinds as &[&str], + &paths as &[&str], + ) + .fetch_all(&mut *tx) + .await?; + if !conflicts.is_empty() { + return Ok(Json(TransferDraftsResponse { + copied: vec![], + removed: vec![], + conflicts: to_refs(conflicts.into_iter().map(|r| (r.typ, r.path)).collect()), + })); + } + copied = windmill_common::user_drafts::copy_drafts_to_workspace( + &mut *tx, + &w_id, + target, + &authed.email, + Some(&items), + ) + .await?; + } + + let mut removed = vec![]; + if req.remove_from_source { + removed = sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 AND email = $2 + AND (typ::text, path) IN (SELECT * FROM unnest($3::text[], $4::text[])) + RETURNING typ AS "typ: UserDraftItemKind", path"#, + &w_id, + &authed.email, + &kinds as &[&str], + &paths as &[&str], + ) + .fetch_all(&mut *tx) + .await? + .into_iter() + .map(|r| (r.typ, r.path)) + .collect(); + } + tx.commit().await?; + + Ok(Json(TransferDraftsResponse { + copied: to_refs(copied), + removed: to_refs(removed), + conflicts: vec![], + })) +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "snake_case")] pub enum MigrateLegacyDraftAction { diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index ebd0ce363f..eb836058e7 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -645,6 +645,77 @@ pub async fn delete_own_draft_for_path( Ok(()) } +/// Copy drafts from `source` into its fork `target`, returning the `(path, typ)` of each row +/// copied. With `items = None` this is the fork-time clone: every row of `email` plus the legacy +/// NULL-email row. With `items`, only `email`'s own rows at those `(path, kind)` pairs. +/// +/// A plain INSERT: the caller guarantees the target holds none of the copied rows (an empty +/// fork, or a conflict check under the same transaction), else `draft_pkey_with_user` aborts it. +/// `created_at` is preserved so the per-tab `last_sync` baseline the editor reads lines up with +/// the source's timeline — otherwise the first save from an open editor would trip the conflict +/// modal on every copied draft. +pub async fn copy_drafts_to_workspace( + conn: &mut sqlx::PgConnection, + source: &str, + target: &str, + email: &str, + items: Option<&[(UserDraftItemKind, String)]>, +) -> Result> { + let (kinds, paths): (Option>, Option>) = match items { + Some(items) => ( + Some(items.iter().map(|(k, _)| k.as_str()).collect()), + Some(items.iter().map(|(_, p)| p.as_str()).collect()), + ), + None => (None, None), + }; + // A script/flow draft carries the principal in its value, and deploying it in the target + // would send a pair naming somebody who may not be a member there. Stripped rather than + // filtered: the address the draft still carries re-derives the target's own principal at + // deploy time, which is the more accurate answer of the two. + let rows = sqlx::query!( + // A script hash is content-addressed and shared between a workspace and its forks, so + // a script draft's base still names a version the target can have. Flow and app ids + // are minted per workspace, so those drafts arrive with no base (staleness falls back + // to the timestamps), lineage field included, or the next autosave would re-derive the + // source id. + // + // `clean` is `strip_json_nul`'s parity rule in SQL, so a pre-sanitizer U+0000 escape + // cannot abort the copy on `to_jsonb` or arrive with its principal unstripped: escaped + // backslashes park on chr(1) (lossless, a `json` value's text cannot hold a raw control + // byte) so only a real NUL is removed, and chr(92) spells the backslash so no escape + // sequence reaches this source file. + r#"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base) + SELECT $2, path, typ, + to_json( + CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END + - CASE WHEN typ = 'flow' THEN 'version_id' + WHEN typ IN ('app', 'raw_app') THEN 'parent_version' + ELSE '' END + ), + created_at, email, + CASE WHEN typ = 'script' THEN base END + FROM ( + SELECT d.path, d.typ, d.created_at, d.email, d.base, + replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean + FROM draft d + WHERE d.workspace_id = $1 + AND CASE WHEN $4::text[] IS NULL THEN d.email = $3 OR d.email IS NULL + ELSE d.email = $3 + AND (d.typ::text, d.path) IN (SELECT * FROM unnest($4::text[], $5::text[])) + END + ) s + RETURNING typ AS "typ: UserDraftItemKind", path"#, + source, + target, + email, + kinds.as_deref() as Option<&[&str]>, + paths.as_deref() as Option<&[&str]>, + ) + .fetch_all(&mut *conn) + .await?; + Ok(rows.into_iter().map(|r| (r.typ, r.path)).collect()) +} + /// Carry every draft at `old_path` over to `new_path` when an item MOVES /// (rename or relocation). A draft is bound to its item by nothing but the path /// string, so without this a move detaches every draft on the item. No owner diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index f9df8adcef..b6c7bd0552 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -120,6 +120,7 @@ import { MarkerSeverity } from 'monaco-editor' import { resource, useDebounce, watch } from 'runed' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { trackPendingKeystrokes } from './editorKeystrokeFlush' const operatingWorkspace = useOperatingWorkspace() // import EditorTheme from './EditorTheme.svelte' @@ -493,6 +494,12 @@ changeChainStart = undefined } + // Same guards as the unmount flush below: only when a keystroke is actually pending. + const untrackKeystrokes = trackPendingKeystrokes(() => { + if (editor && timeoutModel !== undefined) flushPendingChanges() + }) + onDestroy(untrackKeystrokes) + export function append(code: string): void { if (editor) { const lineCount = editor.getModel()?.getLineCount() || 0 diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 8b0c346244..9e2379fe33 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -542,6 +542,18 @@ export class AIChatManager implements ChatViewHost { // when the chat is idle, an auto-resume turn started for them (see // #maybeAutoResumeFromJobs). Ephemeral like queuedMessage — not persisted. pendingJobNotes = $state([]) + // Notes about something that happened outside a turn, delivered with the job notes. + // Persisted on the chat record, unlike the job notes, because nothing re-derives + // them after a reload. + #modelNotes: string[] = [] + /** Tell the model about something that happened outside a turn, on its next turn. */ + async queueModelNote(note: string) { + this.#modelNotes = [...this.#modelNotes, note] + await this.historyManager.setModelNotes( + this.historyManager.getCurrentChatId(), + this.#modelNotes + ) + } // Guards #maybeAutoResumeFromJobs against re-entering while its own turn spins up. #autoResuming = false #jobPollTimer: ReturnType | undefined = undefined @@ -1352,6 +1364,7 @@ export class AIChatManager implements ChatViewHost { this.#jobUpdateReaders.clear() this.backgroundJobs = [] this.pendingJobNotes = [] + this.#modelNotes = [] } /** Merge a status patch into the tool card identified by tool_call_id, or @@ -3898,11 +3911,18 @@ export class AIChatManager implements ChatViewHost { // turn (notify-only wake). Folded into the model-facing text only — the // display bubble keeps this.instructions, and no extra message is added, so // the display↔messages index pairing above stays intact. Ephemeral. + const notes = [...this.#modelNotes, ...this.pendingJobNotes] const jobNotesPreamble = - this.mode === AIMode.GLOBAL && this.pendingJobNotes.length > 0 - ? this.pendingJobNotes.join('\n\n') + '\n\n' - : '' - if (jobNotesPreamble) this.pendingJobNotes = [] + this.mode === AIMode.GLOBAL && notes.length > 0 ? notes.join('\n\n') + '\n\n' : '' + if (jobNotesPreamble) { + this.pendingJobNotes = [] + if (this.#modelNotes.length > 0) { + this.#modelNotes = [] + void this.historyManager + .setModelNotes(this.historyManager.getCurrentChatId(), []) + .catch((e) => console.error('Failed to clear model notes', e)) + } + } const modelInstructions = this.mode === AIMode.GLOBAL ? jobNotesPreamble + this.expandGlobalSkillCommand(oldInstructions) @@ -4652,6 +4672,7 @@ export class AIChatManager implements ChatViewHost { if (this.isJobNonTerminal(j.status)) j.detached = true } if (this.backgroundJobs.length > 0) this.backgroundJobs = [...this.backgroundJobs] + this.#modelNotes = chat.modelNotes ?? [] // Reloading resolves no card on its own. Settle every one the poller above // will not reach, whoever wrote it — a record from a build that stored cards // without their jobs would otherwise restore one that spins forever. diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index 7843169dd9..41a2b4acca 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -51,6 +51,9 @@ interface ChatSchema extends IDBSchema { // in-flight job's tray row and completion survive a reload. Absent on // chats predating this feature. Persisted out-of-band like modifiedItems. backgroundJobs?: ChatJob[] + // Notes queued for the model's next turn (see AIChatManager.queueModelNote), + // so one queued before a reload still reaches it. Written by setModelNotes. + modelNotes?: string[] } indexes: { 'by-session': string } } @@ -328,6 +331,7 @@ export default class HistoryManager { contextUsage?: PersistedContextUsage modifiedItems?: string[] backgroundJobs?: ChatJob[] + modelNotes?: string[] } > = $state({}) @@ -457,6 +461,19 @@ export default class HistoryManager { }) } + async setModelNotes(chatId: string, notes: string[]) { + const existing = this.savedChats[chatId] + if (!existing) return + const updated = { ...$state.snapshot(existing), modelNotes: notes } + this.savedChats = { ...this.savedChats, [chatId]: updated } + await this.enqueueDbWrite(async (db) => { + await db.put('chats', updated) + if (updated.sessionId) { + markSessionDirty(updated.sessionId, chatId, emailOfScopedKey(DB_NAME, db.name)) + } + }) + } + getPastChats() { return this.pastChats } @@ -704,7 +721,11 @@ export default class HistoryManager { ? { backgroundJobs: $state.snapshot(this.savedChats[this.currentChatId].backgroundJobs) } - : {}) + : {}), + // Only setModelNotes changes this field, so every save keeps what it last wrote. + ...(this.savedChats[this.currentChatId]?.modelNotes !== undefined + ? { modelNotes: $state.snapshot(this.savedChats[this.currentChatId].modelNotes) } + : {}) } // The mirror mirrors what the DB holds (refs — the snapshot is // dehydrated below before either sees it): a reopened chat hydrates diff --git a/frontend/src/lib/components/editorKeystrokeFlush.ts b/frontend/src/lib/components/editorKeystrokeFlush.ts new file mode 100644 index 0000000000..55e1a5e414 --- /dev/null +++ b/frontend/src/lib/components/editorKeystrokeFlush.ts @@ -0,0 +1,13 @@ +// Mounted code editors holding keystrokes their change debounce has not yet written to +// `code`. Lets an operation about to move or read drafts make those keystrokes land first. +const pending = new Set<() => void>() + +export function trackPendingKeystrokes(flush: () => void): () => void { + pending.add(flush) + return () => pending.delete(flush) +} + +/** Write every editor's debounced keystrokes to its `code` now. */ +export function flushPendingKeystrokes(): void { + for (const flush of [...pending]) flush() +} diff --git a/frontend/src/lib/components/sessions/SessionMoveModal.svelte b/frontend/src/lib/components/sessions/SessionMoveModal.svelte new file mode 100644 index 0000000000..dbbd31ef13 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionMoveModal.svelte @@ -0,0 +1,80 @@ + + + 0} + {onConfirmed} + {onCanceled} +> +
+

+ The session will act on {targetName}. These + drafts are copied there: +

+
    + {#each items as item (`${item.kind}:${item.path}`)} +
  • + {item.kind.replace('_', ' ')} + {item.label} +
  • + {/each} +
+
+ +
+ Remove these drafts from {parentName} + Leaves {parentName} at its deployed versions. The work then exists only in the fork. +
+
+ {#if conflicts.length > 0} +

+ You already have drafts of the highlighted items in {targetName}. Discard them there first. +

+ {:else if error} +

{error}

+ {/if} +
+
diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 3335de3d33..236a227811 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -9,7 +9,11 @@ import { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' import { userWorkspaces, workspaceStore } from '$lib/stores' import { workspaceIsFork } from '$lib/utils/workspaceHierarchy' - import { WorkspaceService } from '$lib/gen' + import { DraftService, WorkspaceService, type DraftItemRef } from '$lib/gen' + import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte' + import SessionMoveModal from './SessionMoveModal.svelte' + import { moveSessionToFork, type MoveTarget } from './moveSessionToFork' + import { maskHasDraftRow } from './modifiedItemsMask' import { sendUserToast } from '$lib/toast' import Toggle from '$lib/components/Toggle.svelte' import { copilotInfo } from '$lib/aiStore' @@ -327,6 +331,121 @@ await moveSessionToNewFork(session.id, fork) } + // Moving mid-turn would let a tool still running write its draft into the workspace + // being left, after the move copied or removed the others. + const canMoveToFork = $derived( + !!runtime && + !isUnavailable && + !session?.archived && + !runtime.manager.loading && + !runtime.manager.sendInFlight && + !runtime.manager.sendPending && + !runtime.manager.runHeldElsewhere + ) + + type MoveRequest = { + open: boolean + parent: string + parentName: string + target: MoveTarget + targetName: string + items: (DraftItemRef & { label: string })[] + conflicts: DraftItemRef[] + error?: string + } + let moveRequest = $state(undefined) + let moveRemoveFromParent = $state(false) + let moving = $state(false) + + // Every chat of the session counts, not only the one on screen: a user who opened a new + // chat inside the session still expects the earlier edits to come along. + function sessionMask(): Set { + const m = runtime?.manager + if (!m) return new Set() + return new Set([ + ...m.historyManager.getAllSavedChats().flatMap((c) => c.modifiedItems ?? []), + ...(m.modifiedItems ?? []) + ]) + } + + async function startMove(target: MoveTarget) { + if (!session || !acting || !canMoveToFork) return + const parent = acting.targetId + if (target.kind === 'existing' && target.id === parent) return + const mask = sessionMask() + let items: MoveRequest['items'] + let conflicts: DraftItemRef[] = [] + try { + // Only the user's own rows move; a legacy workspace-level draft is nobody's. + items = (await DraftService.listDrafts({ workspace: parent })) + .filter((r) => r.mine && !r.legacy_draft && maskHasDraftRow(mask, r)) + .map((r) => ({ kind: r.kind, path: r.path, label: r.draft_path ?? r.path })) + if (target.kind === 'existing' && items.length > 0) { + const inFork = new Set( + (await DraftService.listDrafts({ workspace: target.id })) + .filter((r) => r.mine && !r.legacy_draft) + .map((r) => `${r.kind}:${r.path}`) + ) + conflicts = items + .filter((i) => inFork.has(`${i.kind}:${i.path}`)) + .map(({ kind, path }) => ({ kind, path })) + } + } catch (e: any) { + sendUserToast(`Could not list the session's drafts: ${e?.body ?? e?.message ?? e}`, true) + return + } + moveRemoveFromParent = false + moveRequest = { + open: items.length > 0, + parent, + parentName: acting.name, + target, + targetName: + target.kind === 'new' + ? target.fork.name + : ($userWorkspaces.find((w) => w.id === target.id)?.name ?? target.id), + items, + conflicts + } + if (items.length === 0) await confirmMove() + } + + async function confirmMove() { + const req = moveRequest + if (!session || !req || moving) return + moving = true + try { + const res = await moveSessionToFork(session.id, req.parent, req.target, { + items: req.items.map(({ kind, path }) => ({ kind, path })), + removeFromParent: moveRemoveFromParent + }) + if (!res.ok) { + if (!req.open) { + sendUserToast(res.error, true) + moveRequest = undefined + } else { + moveRequest = { ...req, error: res.error, conflicts: res.conflicts ?? [] } + } + return + } + moveRequest = undefined + void runtime?.manager + .queueModelNote( + `This session moved from workspace "${req.parent}" to its fork "${res.forkId}": every further tool call acts on "${res.forkId}". ` + + (res.copied > 0 + ? `${res.copied} draft(s) of this session were copied there` + + (res.removed > 0 ? ` and removed from "${req.parent}". ` : '. ') + : '') + + `Jobs from earlier turns ran in "${req.parent}".` + ) + .catch((e) => console.error('Failed to persist the move note', e)) + runtime?.manager.rebuildGlobalSystemMessage() + sendUserToast(`Session moved to ${req.targetName}`) + } finally { + moving = false + } + } + // Chrome shared by the banners stacked above the composer, so restyling one // can't leave the others behind. Each supplies its own background: two bg-* // utilities on one element resolve by stylesheet order, not attribute order. @@ -484,19 +603,50 @@ the "Run in" picker (SessionWorkspaceBar) instead. -->
Acting on - - - - + {#if canMoveToFork} + + startMove({ kind: 'existing', id })} + onCreateFork={(fork) => startMove({ kind: 'new', fork })} + createForkCaption="Created now, and the session moves into it." + confirmForkLabel="Move here" + class="min-w-0" + > + {#snippet trigger()} + + {/snippet} + + + {#snippet buttonReplacement()} + + + + {/snippet} + + {:else} + + + + + {/if}
{/if} @@ -554,6 +704,21 @@ + {#if moveRequest} + void confirmMove()} + onCanceled={() => (moveRequest = undefined)} + /> + {/if} + void allowCreateFork?: boolean createForkCaption?: string + confirmForkLabel?: string class?: string settingsHref?: string settingsLabel?: string forkableWorkspaces?: UserWorkspace[] + forksOf?: string trigger: Snippet<[{ open: boolean }]> } = $props() @@ -100,8 +106,18 @@ enabled: () => forkableWorkspacesProp === undefined }) const forkableWorkspaces = $derived(forkableWorkspacesProp ?? ownForkable.current) - const root = $derived(findWorkspaceRoot(effectiveId, forkableWorkspaces)) - const forks = $derived(root ? findWorkspaceDescendants(root.id, forkableWorkspaces) : []) + const root = $derived( + forksOf + ? forkableWorkspaces.find((w) => w.id === forksOf) + : findWorkspaceRoot(effectiveId, forkableWorkspaces) + ) + const forks = $derived( + forksOf + ? forkableWorkspaces.filter((w) => w.parent_workspace_id === forksOf) + : root + ? findWorkspaceDescendants(root.id, forkableWorkspaces) + : [] + ) // The family's canonical dev workspace, if any — still used for gating (a forking-locked root can be // forked via its dev) and as a selectable base with a "dev" badge. @@ -110,11 +126,13 @@ ) // Base a new fork gets by default: the dev workspace when the selection sits in its subtree, the // family root otherwise. - const defaultForkBase = $derived(findDefaultForkBase(effectiveId, forkableWorkspaces)) + const defaultForkBase = $derived( + forksOf ? root : findDefaultForkBase(effectiveId, forkableWorkspaces) + ) const createForkLabel = 'Create new fork…' // Candidate bases ("targets") for a new fork: the root plus every fork/dev in the family, so a fork // can itself be the base — i.e. a fork of a fork. Root first, matching the list order below. - const baseOptions = $derived(root ? [root, ...forks] : []) + const baseOptions = $derived(root ? (forksOf ? [root] : [root, ...forks]) : []) // Options for the base-branch