feat: move a session to any workspace of its family

A session could only move into a direct fork of its workspace. The picker
now lists the whole family and lets a new fork be based on any of its
workspaces, and /drafts/transfer accepts any target with the same root.
A fork the move creates from elsewhere holds that base's drafts, so its
drafts are never replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-28 02:54:50 -07:00
co-authored by Claude Opus 5.5
parent 9e8eb53f22
commit 98ceb7d647
10 changed files with 114 additions and 86 deletions
@@ -1,23 +0,0 @@
{
"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,23 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE up(id, parent, start, depth) AS (\n SELECT id, parent_workspace_id, id, 0 FROM workspace\n WHERE id IN ($1, $2) AND deleted = false\n UNION ALL\n SELECT w.id, w.parent_workspace_id, up.start, up.depth + 1\n FROM workspace w JOIN up ON w.id = up.parent\n WHERE up.depth < 64\n )\n SELECT (SELECT id FROM up WHERE start = $1 AND parent IS NULL)\n = (SELECT id FROM up WHERE start = $2 AND parent IS NULL) AS \"same\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "same",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": [
null
]
},
"hash": "ac1007f6bdd93ebe9cebd9f1c541d73d17533f1887021374451e3b6e4803c67b"
}
@@ -530,12 +530,26 @@ async fn test_transfer_drafts_to_fork(db: Pool<Postgres>) -> anyhow::Result<()>
(Some("ffffffffffffffff"), Some("ffffffffffffffff"))
);
// Only a fork of the source is a valid target.
// Any workspace of the family takes them: here back from the fork to its parent.
let r = c
.post(format!(
"http://localhost:{port}/api/w/{fork}/drafts/transfer"
))
.json(&json!({ "target_workspace": WS, "items": items }))
.json(&json!({
"target_workspace": WS,
"items": [{ "kind": "script", "path": "u/test-user/a" }],
"remove_from_source": true
}))
.send()
.await?;
assert_eq!(r.status(), 200, "{}", r.text().await?);
assert_eq!(draft_count(&db, "u/test-user/a", "script", me).await, 1);
assert_eq!(fork_count("u/test-user/a").await?, 0);
// A workspace outside the family is refused.
let r = c
.post(&transfer_url)
.json(&json!({ "target_workspace": "admins", "items": items }))
.send()
.await?;
assert_eq!(r.status(), 400);
+2 -2
View File
@@ -11313,8 +11313,8 @@ paths:
/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`, unless `replace_existing` is set. A user who can only compose flows or apps in the target is held to the same rules as a direct draft save there.
summary: copy the current user's drafts into another workspace of the family and/or remove them here
description: In one transaction, copies the authed user's own drafts of `items` into `target_workspace` (another workspace of this one's family, with the same root through `parent_workspace_id`) 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`, unless `replace_existing` is set. A user who can only compose flows or apps in the target is held to the same rules as a direct draft save there.
operationId: transferDrafts
tags:
- draft
+29 -16
View File
@@ -1011,8 +1011,8 @@ pub struct DraftItemRef {
#[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`).
/// A workspace of this one's family (the same root: its forks, its parent, siblings) 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>,
@@ -1035,9 +1035,9 @@ pub struct TransferDraftsResponse {
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.
/// Copy the caller's own drafts of `items` into another workspace of this one's family 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
@@ -1062,21 +1062,34 @@ async fn transfer_drafts(
let mut target_authed = None;
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 {
// Same root, reached through `parent_workspace_id`. The copy keeps what the target can
// resolve (script lineage it has, members it has) and repairs the rest, so any workspace
// of the family can take the drafts, not only a direct fork.
let same_family = target != w_id
&& sqlx::query_scalar!(
r#"WITH RECURSIVE up(id, parent, start, depth) AS (
SELECT id, parent_workspace_id, id, 0 FROM workspace
WHERE id IN ($1, $2) AND deleted = false
UNION ALL
SELECT w.id, w.parent_workspace_id, up.start, up.depth + 1
FROM workspace w JOIN up ON w.id = up.parent
WHERE up.depth < 64
)
SELECT (SELECT id FROM up WHERE start = $1 AND parent IS NULL)
= (SELECT id FROM up WHERE start = $2 AND parent IS NULL) AS "same""#,
target,
&w_id,
)
.fetch_one(&db)
.await?
.unwrap_or(false);
if !same_family {
return Err(Error::BadRequest(format!(
"{target} is not a fork of {w_id}"
"{target} is not in the same workspace family as {w_id}"
)));
}
// Authentication checked the token against `w_id` only. A token bound to that
// workspace, or a job token (not in `token`), must not reach into the fork.
// workspace, or a job token (not in `token`), must not reach into the target.
let token_valid_for_target = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1
AND (expiration > NOW() OR expiration IS NULL)
+1 -1
View File
@@ -665,7 +665,7 @@ pub async fn delete_own_draft_for_path(
/// 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 `(kind, path)` pairs.
///
/// No authorization here. The caller MUST have checked that `target` is a fork of `source`, that
/// No authorization here. The caller MUST have checked that `target` is in `source`'s family, that
/// the credential behind `email` may act on `target`, and that it may write each copied path
/// there. Secret values are copied as encrypted, so the two workspaces must share a key.
///
@@ -72,7 +72,7 @@
<label for="move-remove-from-parent" class="flex flex-col cursor-pointer">
<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
>Leaves {parentName} at its deployed versions. The work then exists only in {targetName}.</span
>
</label>
</div>
@@ -803,7 +803,7 @@
<WorkspaceFamilyPicker
bind:open={movePickerOpen}
selectedId={actingWorkspaceId}
forksOf={actingWorkspaceId}
moveFrom={actingWorkspaceId}
onPick={(id) => startMove({ kind: 'existing', id })}
onCreateFork={(fork) => startMove({ kind: 'new', fork })}
createForkCaption="Created now, and the session moves into it."
@@ -966,7 +966,9 @@
open={moveRequest.open}
parentName={moveRequest.parentName}
targetName={moveRequest.targetName}
newFork={moveRequest.target.kind === 'new' || !!moveRequest.target.replace}
newFork={moveRequest.target.kind === 'new'
? moveRequest.target.fork.parent_workspace_id === moveRequest.parent
: !!moveRequest.target.replace}
items={moveRequest.items}
conflicts={moveRequest.conflicts}
error={moveRequest.error}
@@ -78,9 +78,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,
// The workspace something moves out of: its family is listed, and a new fork is based on it
// unless another base is picked. Its own forking rule and the user's role there gate creating.
moveFrom,
open: dropdownOpen = $bindable(),
trigger
}: {
@@ -96,7 +96,7 @@
settingsHref?: string
settingsLabel?: string
forkableWorkspaces?: UserWorkspace[]
forksOf?: string
moveFrom?: string
open?: boolean
trigger: Snippet<[{ open: boolean }]>
} = $props()
@@ -112,21 +112,11 @@
enabled: () => forkableWorkspacesProp === undefined
})
const forkableWorkspaces = $derived(forkableWorkspacesProp ?? ownForkable.current)
const root = $derived(
forksOf
? forkableWorkspaces.find((w) => w.id === forksOf)
: findWorkspaceRoot(effectiveId, forkableWorkspaces)
)
const root = $derived(findWorkspaceRoot(moveFrom ?? effectiveId, forkableWorkspaces))
// The same hierarchy the sidebar workspace menu builds: forks of forks follow their parent, and the
// user's own forks lead each group of siblings.
const hierarchy = $derived(buildWorkspaceHierarchy(forkableWorkspaces, $userStore?.email))
const forkRows = $derived(
root
? workspaceSubtreeRows(root.id, hierarchy).filter(
(h) => !forksOf || h.workspace.parent_workspace_id === forksOf
)
: []
)
const forkRows = $derived(root ? workspaceSubtreeRows(root.id, hierarchy) : [])
const forks = $derived(forkRows.map((h) => h.workspace))
// The family's canonical dev workspace, if any — still used for gating (a forking-locked root can be
@@ -137,12 +127,14 @@
// Base a new fork gets by default: the dev workspace when the selection sits in its subtree, the
// family root otherwise.
const defaultForkBase = $derived(
forksOf ? root : findDefaultForkBase(effectiveId, forkableWorkspaces)
moveFrom
? forkableWorkspaces.find((w) => w.id === moveFrom)
: 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 ? (forksOf ? [root] : [root, ...forks]) : [])
const baseOptions = $derived(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(
@@ -160,46 +152,46 @@
// 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 = forksOf ? 1 : (familyDepths.get(id) ?? 0)
const depth = familyDepths.get(id) ?? 0
return depth > 0 ? `padding-left: ${12 + depth * 16}px` : undefined
}
// Judged off the root's OWN rules, so it reads correctly from a dev workspace or a fork too.
// A locked root stays selectable: its session is read-only.
const rootReadOnly = useSessionReadOnly(() => root?.id)
// In `forksOf` mode a new fork is based on that workspace, so its own forking rule is the gate,
// judged with the user's identity there (is_admin and groups are per workspace).
const rootRulesetsResource = resource(
() => (forksOf ? root?.id : undefined),
// In `moveFrom` mode a new fork is based on that workspace by default, so its own forking rule is
// the gate, judged with the user's identity there (is_admin and groups are per workspace). Another
// base's own rule is enforced when the fork is created.
const fromRulesetsResource = resource(
() => moveFrom,
async (id) => (id ? await fetchProtectionRulesForWorkspace(id) : [])
)
const rootRulesets = $derived(rootRulesetsResource.current ?? [])
const rootUserInfoResource = resource(
() => (forksOf ? root?.id : undefined),
const fromRulesets = $derived(fromRulesetsResource.current ?? [])
const fromUserInfoResource = resource(
() => moveFrom,
async (id) => (id ? await getUserExt(id) : undefined)
)
const rootUserInfo = $derived(rootUserInfoResource.current)
const fromUserInfo = $derived(fromUserInfoResource.current)
// Structural gate: hidden in the admins workspace, or when the user can't fork; on cloud, forking
// is premium-only (backend caps it per paid seat). DisableWorkspaceForking on the active workspace
// (a locked prod) doesn't apply when there's a dev to fork from instead — the dev isn't locked, and
// devOfRoot only resolves when the user is a member of it.
// In `forksOf` mode the fork is always based on that workspace, so only its own rule counts.
const forksGateOpen = $derived(
(!isCloudHosted() || $maybePremium) &&
(forksOf
? forksOf !== 'admins' &&
!rootRulesetsResource.loading &&
!rootUserInfoResource.loading &&
(moveFrom
? moveFrom !== 'admins' &&
!fromRulesetsResource.loading &&
!fromUserInfoResource.loading &&
// A restricted role would get the default role in the fork it creates.
!isRestricted(
capabilitiesIn(
rootUserInfo,
$userWorkspaces.find((w) => w.id === forksOf)
fromUserInfo,
$userWorkspaces.find((w) => w.id === moveFrom)
)
) &&
(!isRuleActiveInRulesets(rootRulesets, 'DisableWorkspaceForking') ||
canUserBypassRuleKindInRulesets(rootRulesets, 'DisableWorkspaceForking', rootUserInfo))
(!isRuleActiveInRulesets(fromRulesets, 'DisableWorkspaceForking') ||
canUserBypassRuleKindInRulesets(fromRulesets, 'DisableWorkspaceForking', fromUserInfo))
: $workspaceStore !== 'admins' &&
(canCreateFork($userStore, $roleCapabilities) || !!devOfRoot))
)
@@ -438,7 +430,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"
>{forksOf ? 'current' : rootReadOnly.known ? 'read-only' : 'root'}</span
>{rootReadOnly.known ? 'read-only' : 'root'}</span
>
{#if isSelected(root.id) && !pendingFork}
<Check size={14} class="shrink-0 text-accent" />
@@ -457,7 +449,7 @@
{#if separatorBefore}
<div
class="my-1 border-t border-border-light shrink-0"
style:margin-left={`${forksOf ? 0 : ((familyDepths.get(f.id) ?? 1) - 1) * 16}px`}
style:margin-left={`${((familyDepths.get(f.id) ?? 1) - 1) * 16}px`}
role="separator"
></div>
{/if}
@@ -118,7 +118,14 @@ export async function moveSessionToFork(
if (target.kind === 'new') {
const made = await materializeForkOutcome(target.fork)
if (!made) return { ok: false, error: 'Could not create the fork' }
fork = { kind: 'existing', id: made.id, replace: made.created }
// The clone holds this session's drafts only when the fork was made from where the session
// is; one based elsewhere in the family holds that base's, which are not the session's to
// overwrite.
fork = {
kind: 'existing',
id: made.id,
replace: made.created && target.fork.parent_workspace_id === parent
}
// Creating the fork takes seconds, long enough for a job's auto-resume to start a turn.
if (opts.isBusy?.()) return { ok: false, error: busy, retry: fork }
// So does an edit in the session's preview: it lands in the parent, possibly as a draft the