mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 08:16:49 +00:00
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:
co-authored by
Claude Opus 5.5
parent
9e8eb53f22
commit
98ceb7d647
-23
@@ -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"
|
||||
}
|
||||
+23
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user