feat: move a live ai session into a workspace fork with its drafts

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-07 07:27:58 -07:00
co-authored by Claude Opus 5.5
parent af1a29a3cb
commit bdb111c236
22 changed files with 1188 additions and 88 deletions
@@ -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,