mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
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:
co-authored by
Claude Opus 5.5
parent
af1a29a3cb
commit
bdb111c236
+66
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+65
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+65
@@ -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"
|
||||
}
|
||||
-16
@@ -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,
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user