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
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -295,3 +295,100 @@ async fn test_cross_user_draft_privacy(db: Pool<Postgres>) -> 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<Postgres>) -> 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(())
}
@@ -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,
+56
View File
@@ -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: |
+145
View File
@@ -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<String>,
pub items: Vec<DraftItemRef>,
#[serde(default)]
pub remove_from_source: bool,
}
#[derive(Serialize)]
pub struct TransferDraftsResponse {
pub copied: Vec<DraftItemRef>,
pub removed: Vec<DraftItemRef>,
/// 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<DraftItemRef>,
}
/// 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<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<TransferDraftsRequest>,
) -> Result<Json<TransferDraftsResponse>> {
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<DraftItemRef> {
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 {
@@ -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<Vec<(UserDraftItemKind, String)>> {
let (kinds, paths): (Option<Vec<&str>>, Option<Vec<&str>>) = 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
@@ -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
@@ -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<string[]>([])
// 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<typeof setTimeout> | 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.
@@ -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
@@ -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()
}
@@ -0,0 +1,80 @@
<script lang="ts">
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import type { DraftItemRef } from '$lib/gen'
type Item = DraftItemRef & { label: string }
let {
open,
parentName,
targetName,
items,
conflicts = [],
error,
loading = false,
removeFromParent = $bindable(),
onConfirmed,
onCanceled
}: {
open: boolean
parentName: string
targetName: string
items: Item[]
conflicts?: DraftItemRef[]
error?: string
loading?: boolean
removeFromParent: boolean
onConfirmed: () => void
onCanceled: () => void
} = $props()
const conflictKeys = $derived(new Set(conflicts.map((c) => `${c.kind}:${c.path}`)))
</script>
<ConfirmationModal
{open}
title="Move session to {targetName}"
confirmationText={error && conflicts.length === 0 ? 'Retry' : 'Move'}
type="info"
alwaysPortal
{loading}
confirmDisabled={conflicts.length > 0}
{onConfirmed}
{onCanceled}
>
<div class="flex flex-col gap-3">
<p>
The session will act on <span class="font-medium text-primary">{targetName}</span>. These
drafts are copied there:
</p>
<ul class="flex flex-col gap-0.5 max-h-48 overflow-y-auto text-xs">
{#each items as item (`${item.kind}:${item.path}`)}
<li class="flex items-center gap-2 min-w-0">
<span class="text-2xs text-tertiary shrink-0 w-14">{item.kind.replace('_', ' ')}</span>
<span
class="truncate font-mono {conflictKeys.has(`${item.kind}:${item.path}`)
? 'text-red-600 dark:text-red-400'
: 'text-primary'}">{item.label}</span
>
</li>
{/each}
</ul>
<div class="flex items-start gap-2 border rounded-md p-3 bg-surface-secondary">
<Toggle size="xs" bind:checked={removeFromParent} />
<div class="flex flex-col">
<span class="text-xs font-medium text-primary">Remove these drafts from {parentName}</span>
<span class="text-3xs text-tertiary"
>Leaves {parentName} at its deployed versions. The work then exists only in the fork.</span
>
</div>
</div>
{#if conflicts.length > 0}
<p class="text-xs text-red-600 dark:text-red-400">
You already have drafts of the highlighted items in {targetName}. Discard them there first.
</p>
{:else if error}
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
{/if}
</div>
</ConfirmationModal>
@@ -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<MoveRequest | undefined>(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<string> {
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. -->
<div class="flex items-center gap-1 min-w-0 text-2xs text-tertiary">
<span class="shrink-0">Acting on</span>
<!-- Hover reveals the workspace name + id + copy button (shared
NameIdTooltip, same as the sidebar family picker), so the chip
carries the copy affordance without an inline button. -->
<NameIdTooltip name={acting.name} id={acting.targetId}>
<WorkspaceScopeTrigger
workspaceId={acting.targetId}
showChevron={false}
interactive={false}
disableTitle
class="max-w-[16rem]"
menuItems={actingMenu}
/>
</NameIdTooltip>
{#if canMoveToFork}
<!-- Picking a fork moves the session there (see startMove). -->
<WorkspaceFamilyPicker
selectedId={acting.targetId}
forksOf={acting.targetId}
onPick={(id) => 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()}
<WorkspaceScopeTrigger
workspaceId={acting.targetId}
disableTitle
class="max-w-[16rem]"
/>
{/snippet}
</WorkspaceFamilyPicker>
<DropdownV2 items={actingMenu} placement="bottom-end" fixedHeight={false}>
{#snippet buttonReplacement()}
<span
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
title="Workspace actions"
>
<EllipsisVertical size={14} />
</span>
{/snippet}
</DropdownV2>
{:else}
<!-- Hover reveals the workspace name + id + copy button (shared
NameIdTooltip, same as the sidebar family picker), so the chip
carries the copy affordance without an inline button. -->
<NameIdTooltip name={acting.name} id={acting.targetId}>
<WorkspaceScopeTrigger
workspaceId={acting.targetId}
showChevron={false}
interactive={false}
disableTitle
class="max-w-[16rem]"
menuItems={actingMenu}
/>
</NameIdTooltip>
{/if}
</div>
{/if}
</header>
@@ -554,6 +704,21 @@
</div>
</ConfirmationModal>
{#if moveRequest}
<SessionMoveModal
open={moveRequest.open}
parentName={moveRequest.parentName}
targetName={moveRequest.targetName}
items={moveRequest.items}
conflicts={moveRequest.conflicts}
error={moveRequest.error}
loading={moving}
bind:removeFromParent={moveRemoveFromParent}
onConfirmed={() => void confirmMove()}
onCanceled={() => (moveRequest = undefined)}
/>
{/if}
<ConfirmationModal
open={archiveConfirmOpen}
title="Archive session"
@@ -61,6 +61,7 @@
// consumer differentiate "staged for first send" vs. "will be
// created immediately" semantics.
createForkCaption = '',
confirmForkLabel = 'Set as target',
// Forwarded to the dropdown trigger wrapper — e.g. `min-w-0` so the
// trigger can shrink and its label truncate inside a narrow container.
class: triggerClass = undefined,
@@ -73,6 +74,9 @@
// WorkspaceScopeHeader), so the superadmin lookup isn't duplicated. Omitted
// by standalone consumers, which then resolve it themselves.
forkableWorkspaces: forkableWorkspacesProp,
// Narrow the list to this workspace and its direct forks, and fork only from it —
// for moving something into a fork of where it is, rather than anywhere in the family.
forksOf,
trigger
}: {
selectedId?: string
@@ -82,10 +86,12 @@
onRequestCreateFork?: () => 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 <Select>. The nesting (fork of a fork) is rendered via the Select's
// per-item `startSnippet` as a depth-based spacer, so the label text itself stays clean.
const baseItems = $derived(
@@ -135,7 +153,7 @@
// Extra left padding (on top of the row's base px-3) to nest a workspace one step per depth level,
// matching the sidebar menu's `depth * 16px`.
function indentStyle(id: string): string | undefined {
const depth = familyDepths.get(id) ?? 0
const depth = forksOf ? 1 : (familyDepths.get(id) ?? 0)
return depth > 0 ? `padding-left: ${12 + depth * 16}px` : undefined
}
@@ -165,8 +183,9 @@
// whether a dev workspace exists to steer to — being deploy-locked is the gate, not dev presence.
// Roots with no rules resolve `canDeployRoot` to true, so ordinary families aren't affected. Kept
// disabled while either fetch is in flight (both default to the conservative locked state).
// In `forksOf` mode the top row is where the consumer already is, so there is nothing to gate.
const rootDisabled = $derived(
rootRulesetsResource.loading || rootUserInfoResource.loading || !canDeployRoot
!forksOf && (rootRulesetsResource.loading || rootUserInfoResource.loading || !canDeployRoot)
)
// Structural gate: hidden in the admins workspace, or when the user can't fork; on cloud, forking
@@ -415,7 +434,7 @@
<Building size={14} class="shrink-0 text-tertiary" />
<span class="truncate">{root.name}</span>
<span class="text-2xs text-tertiary shrink-0 ml-auto"
>{rootDisabled ? 'locked' : 'root'}</span
>{rootDisabled ? 'locked' : forksOf ? 'current' : 'root'}</span
>
{#if isSelected(root.id) && !pendingFork}
<Check size={14} class="shrink-0 text-accent" />
@@ -543,7 +562,7 @@
disabled={!newForkId.trim() || !!forkIdError || staging}
on:click={() => void stageNewFork()}
>
Set as target
{confirmForkLabel}
</Button>
</div>
</div>
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { get } from 'svelte/store'
import { DraftService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sessionState, type Session } from './sessionState.svelte'
import { moveSessionToFork } from './moveSessionToFork'
vi.mock('$lib/gen', async (orig) => {
const actual = await orig<typeof import('$lib/gen')>()
return {
...actual,
DraftService: { ...actual.DraftService, transferDrafts: vi.fn() }
}
})
const items = [{ kind: 'script' as const, path: 'u/a/s' }]
function addSession(): Session {
const s = { id: 'move-test', name: 'm', createdAt: 0, workspace_id: 'root_ws' } as Session
sessionState.sessions.push(s)
return sessionState.sessions.find((x) => x.id === s.id)!
}
afterEach(() => {
const i = sessionState.sessions.findIndex((x) => x.id === 'move-test')
if (i >= 0) sessionState.sessions.splice(i, 1)
})
describe('moveSessionToFork', () => {
it('keeps the session on its workspace when the fork already has a draft of an item', async () => {
vi.mocked(DraftService.transferDrafts).mockResolvedValue({
copied: [],
removed: [],
conflicts: items
})
const s = addSession()
const res = await moveSessionToFork(
s.id,
'root_ws',
{ kind: 'existing', id: 'wm-fork-x' },
{
items,
removeFromParent: true
}
)
expect(res).toMatchObject({ ok: false, conflicts: items })
expect(s.workspace_id).toBe('root_ws')
})
it('moves only the session, not the navigation workspace, once the drafts are copied', async () => {
vi.mocked(DraftService.transferDrafts).mockResolvedValue({
copied: items,
removed: [],
conflicts: []
})
const prev = get(workspaceStore)
workspaceStore.set('root_ws')
try {
const s = addSession()
const res = await moveSessionToFork(
s.id,
'root_ws',
{ kind: 'existing', id: 'wm-fork-x' },
{
items,
removeFromParent: false
}
)
expect(res).toMatchObject({ ok: true, forkId: 'wm-fork-x', copied: 1 })
expect(DraftService.transferDrafts).toHaveBeenCalledWith({
workspace: 'root_ws',
requestBody: { target_workspace: 'wm-fork-x', items, remove_from_source: false }
})
expect(s.workspace_id).toBe('wm-fork-x')
expect(get(workspaceStore)).toBe('root_ws')
} finally {
workspaceStore.set(prev)
}
})
})
@@ -0,0 +1,105 @@
import { tick } from 'svelte'
import { DraftService, type DraftItemRef } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { invalidateWorkspaceDiffCache } from '$lib/components/copilot/chat/global/diffSnapshot'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { flushPendingKeystrokes } from '$lib/components/editorKeystrokeFlush'
import { flushEditorSaves } from './useUserDraftSync.svelte'
import { materializeFork, moveSessionToWorkspace, type PendingFork } from './sessionState.svelte'
export type MoveTarget = { kind: 'existing'; id: string } | { kind: 'new'; fork: PendingFork }
export type MoveSessionResult =
| { ok: true; forkId: string; copied: number; removed: number }
| { ok: false; error: string; conflicts?: DraftItemRef[] }
/**
* Move a session into a fork of its workspace, bringing its drafts along. The session's
* workspace changes only once its drafts are where they must be, so a failure at any step
* leaves it acting on the parent with its drafts intact.
*
* A new fork already receives every draft of the user through the fork clone, so only an
* existing fork needs the copy. Retrying a failed new-fork move with the same `target` is
* safe: `materializeFork` adopts the fork it created the first time.
*/
export async function moveSessionToFork(
sessionId: string,
parent: string,
target: MoveTarget,
opts: { items: DraftItemRef[]; removeFromParent: boolean }
): Promise<MoveSessionResult> {
// A save still debounced in an open editor would otherwise land in the parent after the
// move: re-creating a draft just removed there, or missing from the copy. Three debounces
// stand between a keystroke and the server, and each hands on to the next through an
// effect, hence the ticks.
flushPendingKeystrokes()
await tick()
flushEditorSaves(parent)
await tick()
const unsaved = await UserDraftDbSyncer.flushWorkspace(parent)
if (unsaved.length > 0) {
return {
ok: false,
error: `Could not save ${unsaved.map((q) => q.path).join(', ')} before moving. Resolve it and retry.`
}
}
let forkId: string
let copied = 0
let removed = 0
try {
if (target.kind === 'new') {
const created = await materializeFork(target.fork)
if (!created) return { ok: false, error: 'Could not create the fork' }
forkId = created
copied = opts.items.length
if (opts.removeFromParent && opts.items.length > 0) {
const res = await DraftService.transferDrafts({
workspace: parent,
requestBody: { items: opts.items, remove_from_source: true }
})
removed = res.removed.length
}
} else {
forkId = target.id
if (opts.items.length > 0) {
const res = await DraftService.transferDrafts({
workspace: parent,
requestBody: {
target_workspace: forkId,
items: opts.items,
remove_from_source: opts.removeFromParent
}
})
if (res.conflicts.length > 0) {
return {
ok: false,
error: 'You already have drafts of these items in the fork',
conflicts: res.conflicts
}
}
copied = res.copied.length
removed = res.removed.length
}
}
} catch (e: any) {
return { ok: false, error: String(e?.body ?? e?.message ?? e) }
}
if (opts.removeFromParent) {
for (const it of opts.items) UserDraft.forgetLocal(it.kind, it.path, { workspace: parent })
}
await moveSessionToWorkspace(sessionId, forkId)
for (const ws of [parent, forkId]) {
invalidateWorkspaceDrafts(ws)
invalidateWorkspaceDiffCache(ws)
}
logFeatureUsage('ai_session', 'moved_to_fork', {
key: opts.removeFromParent ? 'copy_and_clean_parent' : 'copy',
entityId: sessionId,
workspace: forkId
})
return { ok: true, forkId, copied, removed }
}
@@ -46,6 +46,18 @@ export interface UserDraftSyncOptions<Draft> {
codec: () => DraftSyncCodec<Draft>
}
/** Every mounted editor's debounced-but-unwritten save, with the workspace it writes to. */
const pendingEditorSaves = new Map<() => void, string>()
/**
* Hand every open session editor's debounced save for `workspace` to `UserDraft` now. It
* then sits parked in the syncer, so follow with `UserDraftDbSyncer.flushWorkspace` to
* have it land.
*/
export function flushEditorSaves(workspace: string): void {
for (const [save, ws] of [...pendingEditorSaves]) if (ws === workspace) save()
}
/**
* Bidirectional sync between a session editor's runtime store and the shared
* `UserDraft` cell for `(workspace, kind, path)`. Holding a *live* handle
@@ -124,6 +136,7 @@ export function useUserDraftSync<Draft>(opts: UserDraftSyncOptions<Draft>): void
clearTimeout(outboundTimer)
outboundTimer = undefined
}
pendingEditorSaves.delete(save)
pendingFlush = undefined
untrack(() => {
const current = UserDraft.get<Draft>(codec.itemKind, path, { workspace })
@@ -134,7 +147,9 @@ export function useUserDraftSync<Draft>(opts: UserDraftSyncOptions<Draft>): void
UserDraft.save<Draft>(codec.itemKind, path, toSave, { workspace })
})
}
if (pendingFlush) pendingEditorSaves.delete(pendingFlush)
pendingFlush = save
pendingEditorSaves.set(save, workspace)
if (outboundTimer) clearTimeout(outboundTimer)
outboundTimer = setTimeout(save, codec.debounceMs)
})
@@ -753,5 +753,23 @@ export const UserDraftDbSyncer = {
await runner.settled(key)
// A save that landed while we waited can have scheduled the next one.
debouncer.cancel(key)
},
/**
* Save every parked write of `workspace` now, auto-save toggle or not, and return the
* drafts that still did not land (a failed POST or a conflict). For an operation that is
* about to read or move the workspace's drafts server-side: a write still parked would
* land after it, in the wrong place.
*/
async flushWorkspace(workspace: string): Promise<UserDraftLastSyncQuery[]> {
const parked = [...pendingSaveOpts.values()].filter((o) => o.workspace === workspace)
await Promise.all(parked.map((o) => this.save({ ...o, immediate: true })))
await Promise.all(parked.map((o) => runner.settled(draftKey(o.workspace, o.itemKind, o.path))))
return parked
.map((o) => ({ workspace: o.workspace, itemKind: o.itemKind, path: o.path }))
.filter((q) => {
const key = draftKey(q.workspace, q.itemKind, q.path)
return failures.has(key) || conflicts.has(key)
})
}
}