From 6e4d8065b160471a6d8e94cdc13cb8a2d968e3aa Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Fri, 7 Aug 2026 18:51:02 +0200 Subject: [PATCH] feat: move draft-only items and warn editors when an item moves Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/components/MoveDrawer.svelte | 33 +++++- .../DraftEditorModals.svelte | 18 +-- .../confirmationModal/DraftMovedModal.svelte | 104 ++++++++++++++++++ .../lib/components/common/table/AppRow.svelte | 14 +++ .../components/common/table/FlowRow.svelte | 13 +++ .../components/common/table/ScriptRow.svelte | 13 +++ .../lib/components/home/BulkActionsBar.svelte | 3 +- .../lib/components/home/bulkActions.test.ts | 15 ++- .../src/lib/components/home/bulkActions.ts | 42 +++++-- .../src/lib/components/moveRenameManager.ts | 14 ++- frontend/src/lib/userDraftDbSyncer.svelte.ts | 40 +++++++ .../scripts/edit/[...path]/+page.svelte | 13 +++ 12 files changed, 298 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/components/common/confirmationModal/DraftMovedModal.svelte diff --git a/frontend/src/lib/components/MoveDrawer.svelte b/frontend/src/lib/components/MoveDrawer.svelte index 21b155fce3..f2e02e2edd 100644 --- a/frontend/src/lib/components/MoveDrawer.svelte +++ b/frontend/src/lib/components/MoveDrawer.svelte @@ -8,12 +8,20 @@ import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager' import Label from './Label.svelte' import TextInput from './text_input/TextInput.svelte' - import { FlowService, ScriptService, type TriggersCount } from '$lib/gen' + import { DraftService, FlowService, ScriptService, type TriggersCount } from '$lib/gen' const dispatch = createEventDispatcher() type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app' + /** Where a draft-only item's draft row actually lives. It is parked at a + * generated path while the drawer edits the name the user sees, so the two + * can't be the same string. Empty for a deployed item, which is addressed by + * `initialPath` throughout. */ + let storagePath = $state('') + let rawApp = $state(false) + let draftOnly = $derived(storagePath !== '') + let kind = $state('flow') let initialPath = $state('') let initialSummary = $state('') @@ -66,21 +74,31 @@ }) let attachedTotal = $derived(attachedSummary.reduce((s, { count }) => s + count, 0)) + /** `draft` marks an item that exists only as the caller's draft: pass the + * generated path its draft row sits at, and `initialPath_l` is then the name + * the user sees. Nothing is deployed, so there are no triggers to cascade + * and no on-behalf-of identity to warn about. */ export async function openDrawer( initialPath_l: string, summary_l: string | undefined, - kind_l: Kind + kind_l: Kind, + draft?: { storagePath: string; rawApp?: boolean } ) { kind = kind_l path = undefined dirtyPath = false onBehalfOfEmail = undefined attachedTriggers = undefined + storagePath = draft?.storagePath ?? '' + rawApp = draft?.rawApp ?? false initialPath = initialPath_l initialSummary = summary_l ?? '' summary = summary_l loadOwner() drawer.openDrawer() + if (draftOnly) { + return + } if (kind === 'flow') { onBehalfOfEmail = await checkFlowOnBehalfOf($workspaceStore!, initialPath_l) } @@ -103,11 +121,18 @@ } function loadOwner() { - own = isOwner(initialPath, $userStore!, $workspaceStore!) + own = isOwner(draftOnly ? storagePath : initialPath, $userStore!, $workspaceStore!) } async function updatePath() { - if (kind === 'flow' || kind === 'script' || kind === 'app') { + if (draftOnly && (kind === 'flow' || kind === 'script' || kind === 'app')) { + await DraftService.moveDraft({ + workspace: $workspaceStore!, + kind: kind === 'app' && rawApp ? 'raw_app' : kind, + path: storagePath, + requestBody: { new_path: path ?? '', summary: summary ?? '' } + }) + } else if (kind === 'flow' || kind === 'script' || kind === 'app') { await updateItemPathAndSummary({ workspace: $workspaceStore!, kind, diff --git a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte index 26366325b5..147614dc0e 100644 --- a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte +++ b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte @@ -2,6 +2,7 @@ /** * The draft modals every editor route mounts at its trailer: * - DraftSyncConflictModal: surfaces a 409 from the autosave pipeline. + * - DraftMovedModal: the item was moved away from this path mid-edit. * - OtherUsersDraftsModal: other users' drafts at this path, for forking. * - StaleDraftModal: prompts when the user's draft predates the latest * deploy; open-state is computed here from the route's timestamps. @@ -12,6 +13,7 @@ */ import type { UserDraftItemKind } from '$lib/gen' import DraftSyncConflictModal from './DraftSyncConflictModal.svelte' + import DraftMovedModal from './DraftMovedModal.svelte' import OtherUsersDraftsModal, { type OtherDraftUser } from './OtherUsersDraftsModal.svelte' import StaleDraftModal from './StaleDraftModal.svelte' import ConfirmationModal from './ConfirmationModal.svelte' @@ -38,13 +40,14 @@ draftSavedAt?: string | undefined /** ISO timestamp of the latest deploy at this path. */ deployedAt?: string | undefined - /** Precise staleness inputs (flows/apps): the deployed version the draft was - * forked from, and the current deployed head. When both are set they drive - * `isStale` and the dedup key instead of the timestamps — exact, and stable - * across autosaves (the timestamp drifts past `deployedAt` as you keep - * editing). Absent (pre-feature drafts, scripts) ⇒ timestamp fallback. */ - draftBaseVersion?: number | undefined - deployedHeadVersion?: number | undefined + /** Precise staleness inputs: the deployed version the draft was forked from, + * and the current deployed head. When both are set they drive `isStale` and + * the dedup key instead of the timestamps — exact, and stable across + * autosaves (the timestamp drifts past `deployedAt` as you keep editing). + * Flows/apps pass version ids; scripts pass hashes, which are strings. + * Absent (pre-feature drafts) ⇒ timestamp fallback. */ + draftBaseVersion?: number | string | undefined + deployedHeadVersion?: number | string | undefined /** Discard the draft and reload deployed (same as "Reset to deployed"). */ onLoadLatestDeploy?: () => void | Promise /** Defaults to true; set to false to suppress all modals. */ @@ -113,6 +116,7 @@ {onLoadFromServer} {getLocalDraft} /> + {#if otherDraftsUsers.length > 0} {#key path} + /** + * Surfaces the "moved" verdict left by `UserDraftDbSyncer.postSave`: someone + * moved this item while the editor was open, so the server refused the + * autosave rather than plant a phantom draft-only item at the path the item + * has left. + * + * Continuing pushes the current in-memory draft to the new path (force, since + * the draft carried over by the move is older) and follows it there, so edits + * made after the move aren't lost to the relocation. + */ + import { base } from '$app/paths' + import { goto } from '$app/navigation' + import { UserDraftDbSyncer, type UserDraftLastSyncQuery } from '$lib/userDraftDbSyncer.svelte' + import Modal2 from '$lib/components/common/modal/Modal2.svelte' + import Button from '$lib/components/common/button/Button.svelte' + import { FolderInput } from 'lucide-svelte' + + type Props = { + query: UserDraftLastSyncQuery + /** Current local draft value, re-pointed at the new path before it is + * pushed there. */ + getLocalDraft: () => unknown + } + + let { query, getLocalDraft }: Props = $props() + + const moveHandle = $derived(UserDraftDbSyncer.getMove(query)) + let isOpen = $derived(moveHandle.move !== undefined) + let busy = $state(false) + + const EDITOR_SEGMENT: Partial> = { + script: 'scripts/edit', + flow: 'flows/edit', + app: 'apps/edit', + raw_app: 'apps_raw/edit' + } + + /** Mirrors the backend's `UserDraftItemKind::typed_path_field`: a script + * draft round-trips its own `path`, every other kind writes `draft_path`. */ + function repointed(value: unknown, newPath: string): unknown { + if (value == undefined || typeof value !== 'object') return value + const field = query.itemKind === 'script' ? 'path' : 'draft_path' + return { ...(value as Record), [field]: newPath } + } + + async function continueThere() { + const move = moveHandle.move + if (!move) return + busy = true + try { + const local = getLocalDraft() + if (local != undefined) { + await UserDraftDbSyncer.overwrite({ + workspace: query.workspace, + itemKind: query.itemKind, + path: move.movedTo, + value: repointed(local, move.movedTo) + }) + } + UserDraftDbSyncer.clearMove(query) + const seg = EDITOR_SEGMENT[query.itemKind] + if (seg) await goto(`${base}/${seg}/${move.movedTo}`) + } finally { + busy = false + } + } + + + +
+
+ +
+

+ {#if moveHandle.move?.movedBy} + {moveHandle.move.movedBy} moved this to + {:else} + This was moved to + {/if} + {moveHandle.move?.movedTo}. Your draft moved with + it, so nothing was saved here. +

+

+ Continuing takes your current edits to the new path. Staying here leaves them unsaved. +

+
+
+ +
+ + +
+
+
diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 7e553143bb..ca66288ea0 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -189,6 +189,20 @@ if (draft_only) { return [ ...selectMenuItems(rowSelection), + { + displayName: 'Move/Rename', + icon: FolderOpen, + action: () => { + // Addressed by the generated path its draft row sits at, but + // named by the path typed in the editor. + moveDrawer.openDrawer((app as any).draft_path ?? path, summary, 'app', { + storagePath: path, + rawApp: !!app.raw_app + }) + }, + disabled: !showEditButton, + hide: $userStore?.operator + }, { displayName: 'Delete', icon: Trash, diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index 55d4b2b142..86e4bfbd2f 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -211,6 +211,19 @@ if (draft_only) { return [ ...selectMenuItems(rowSelection), + { + displayName: 'Move/Rename', + icon: FolderOpen, + action: () => { + // Addressed by the generated path its draft row sits at, but + // named by the path typed in the editor. + moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', { + storagePath: path + }) + }, + disabled: !showEditButton, + hide: $userStore?.operator + }, { displayName: 'Delete', icon: Trash, diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 49e290f7fc..4d9e40970f 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -274,6 +274,19 @@ showCode(script.path, script.summary) } }, + { + displayName: 'Move/Rename', + icon: FolderOpen, + action: () => { + // Addressed by the generated path its draft row sits at, but + // named by the path typed in the editor. + moveDrawer.openDrawer(script.draft_path ?? script.path, script.summary, 'script', { + storagePath: script.path + }) + }, + disabled: !showEditButton, + hide: $userStore?.operator + }, { displayName: 'Delete', icon: Trash, diff --git a/frontend/src/lib/components/home/BulkActionsBar.svelte b/frontend/src/lib/components/home/BulkActionsBar.svelte index 05370326e2..3139d2e43c 100644 --- a/frontend/src/lib/components/home/BulkActionsBar.svelte +++ b/frontend/src/lib/components/home/BulkActionsBar.svelte @@ -17,6 +17,7 @@ eligible, movedPath, runBulk, + sourcePath, type BulkAction, type BulkContext, type BulkOutcome @@ -266,7 +267,7 @@ {@const target = moveTarget} {@render pathList( 'Will be moved to', - pendingItems.map((i) => `${i.path} → ${movedPath(i, target)}`) + pendingItems.map((i) => `${sourcePath(i)} → ${movedPath(i, target)}`) )} {/if} {:else if pending === 'discard'} diff --git a/frontend/src/lib/components/home/bulkActions.test.ts b/frontend/src/lib/components/home/bulkActions.test.ts index 4710019671..d69c3b30ff 100644 --- a/frontend/src/lib/components/home/bulkActions.test.ts +++ b/frontend/src/lib/components/home/bulkActions.test.ts @@ -56,10 +56,12 @@ describe('blockedReason', () => { expect(blockedReason('unarchive', item({ archived: false }), admin)).toBeDefined() }) - it('routes a draft-only row to discard, never to move/archive/delete', () => { + it('lets a draft-only row move or discard, never archive/delete', () => { const draftOnly = item({ draftOnly: true, isDraft: true }) expect(blockedReason('discard', draftOnly, admin)).toBeUndefined() - expect(blockedReason('move', draftOnly, admin)).toBeDefined() + // Moving one rewrites its own draft row (DraftService.moveDraft) — there is + // no deployed path, but there is somewhere for it to go. + expect(blockedReason('move', draftOnly, admin)).toBeUndefined() expect(blockedReason('archive', draftOnly, admin)).toBeDefined() expect(blockedReason('delete', draftOnly, admin)).toBeDefined() }) @@ -75,4 +77,13 @@ describe('movedPath', () => { expect(movedPath(item({ path: 'f/alpha/sub/x' }), 'f/beta')).toBe('f/beta/sub/x') expect(movedPath(item({ path: 'u/ana/x' }), 'f/beta')).toBe('f/beta/x') }) + + it('names a draft-only row by what it displays, not the path it is parked at', () => { + const parked = item({ + draftOnly: true, + path: 'u/ana/draft_9f3c', + displayPath: 'u/ana/my_script' + }) + expect(movedPath(parked, 'f/beta')).toBe('f/beta/my_script') + }) }) diff --git a/frontend/src/lib/components/home/bulkActions.ts b/frontend/src/lib/components/home/bulkActions.ts index 161ddb0d72..1d7492d5a7 100644 --- a/frontend/src/lib/components/home/bulkActions.ts +++ b/frontend/src/lib/components/home/bulkActions.ts @@ -8,11 +8,17 @@ * addresses the deployed row. A draft-only item is therefore not deletable — * there is nothing deployed at its path. */ -import { AppService, FlowService, ScriptService } from '$lib/gen' +import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen' +import type { UserDraftItemKind } from '$lib/gen' import { updateItemPathAndSummary } from '$lib/components/moveRenameManager' import { discardDraft } from '$lib/utils_draft_deploy' import type { BulkItem } from './homeSelection.svelte' +/** The draft overlay is the one place a raw app is its own kind. */ +function draftKind(item: BulkItem): UserDraftItemKind { + return item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind +} + export type BulkAction = 'move' | 'archive' | 'unarchive' | 'delete' | 'discard' export type BulkContext = { @@ -32,7 +38,6 @@ export function blockedReason( const notOwner = 'you are not an owner of this path' switch (action) { case 'move': - if (item.draftOnly) return 'a draft-only item has no deployed path to move' if (item.archived) return 'archived items cannot be moved' if (!item.owner) return notOwner if (!item.canWrite) return 'you do not have write permission on this path' @@ -64,17 +69,35 @@ export function eligible(action: BulkAction, items: BulkItem[], ctx: BulkContext return items.filter((i) => blockedReason(action, i, ctx) == undefined) } +/** The path a move reads from. A draft-only item is parked at a generated + * storage path but named by what was typed in the editor, and it is that name + * the user expects to find under the target. */ +export function sourcePath(item: BulkItem): string { + return item.draftOnly ? item.displayPath : item.path +} + /** Where an item lands under `target` (`f/` or `u/`): everything * below its own owner prefix is preserved, so nested paths keep their shape. */ export function movedPath(item: BulkItem, target: string): string { - const rest = item.path.split('/').slice(2).join('/') + const rest = sourcePath(item).split('/').slice(2).join('/') return `${target}/${rest}` } async function moveItem(ctx: BulkContext, item: BulkItem, target: string): Promise { const newPath = movedPath(item, target) // Re-saving a script at its current path would mint a pointless new version. - if (newPath === item.path) return + if (newPath === sourcePath(item)) return + if (item.draftOnly) { + // Nothing is deployed at this path, so there is no deploy to re-run: the + // item IS its draft row, and moving it rewrites that row. + await DraftService.moveDraft({ + workspace: ctx.workspace, + kind: draftKind(item), + path: item.path, + requestBody: { new_path: newPath } + }) + return + } await updateItemPathAndSummary({ workspace: ctx.workspace, kind: item.kind, @@ -117,10 +140,15 @@ async function deleteItem(ctx: BulkContext, item: BulkItem): Promise { } async function discardItemDraft(ctx: BulkContext, item: BulkItem): Promise { - // The draft overlay is the one place a raw app is its own kind. - const kind = item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind // invalidate=false: the caller refreshes the draft list once for the batch. - const res = await discardDraft(kind, item.path, ctx.workspace, item.draftOnly, false, false) + const res = await discardDraft( + draftKind(item), + item.path, + ctx.workspace, + item.draftOnly, + false, + false + ) if (!res.success) throw new Error(res.error ?? 'discard failed') } diff --git a/frontend/src/lib/components/moveRenameManager.ts b/frontend/src/lib/components/moveRenameManager.ts index 12cc009cb7..1500f8fc69 100644 --- a/frontend/src/lib/components/moveRenameManager.ts +++ b/frontend/src/lib/components/moveRenameManager.ts @@ -21,6 +21,11 @@ export async function checkFlowOnBehalfOf( * * Note: on_behalf_of_email is intentionally omitted from flow updates for security * reasons — the backend will redeploy the flow on behalf of the current user. + * + * `skip_draft_deletion` on every call: this re-deploys the DEPLOYED content at a + * new path, so the caller's draft is unrelated work, not the thing being + * deployed. Without the flag the backend would delete it. The backend carries + * every remaining draft at the old path over to the new one. */ export async function updateItemPathAndSummary(opts: { workspace: string @@ -47,7 +52,8 @@ export async function updateItemPathAndSummary(opts: { dedicated_worker: flow.dedicated_worker, ws_error_handler_muted: flow.ws_error_handler_muted, visible_to_runner_only: flow.visible_to_runner_only, - labels + labels, + skip_draft_deletion: true } }) } else if (kind === 'script') { @@ -61,7 +67,8 @@ export async function updateItemPathAndSummary(opts: { lock: script.lock, parent_hash: script.hash, path: newPath, - labels + labels, + skip_draft_deletion: true } }) } else if (kind === 'app') { @@ -71,7 +78,8 @@ export async function updateItemPathAndSummary(opts: { requestBody: { path: newPath !== initialPath ? newPath : undefined, summary: newSummary, - labels + labels, + skip_draft_deletion: true } }) } diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index 0e8257023d..e36ecd694c 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -129,6 +129,12 @@ export type DraftConflictInfo = { localLastSync: string | null } +/** Where an item went after someone moved it, as reported by a refused save. */ +export type DraftMovedInfo = { + movedTo: string + movedBy: string | undefined +} + export type UserDraftLastSyncQuery = { workspace: string itemKind: UserDraftItemKind @@ -214,6 +220,14 @@ const syncLocked = new Map void) | undefined>() */ const conflicts = new SvelteMap() +/** + * Keys whose item was MOVED out from under an editor still bound to the old + * path. The server refuses the write (saving would plant a phantom draft-only + * item where the item no longer is) and answers with where it went; read via + * `getMove(query)` to prompt the user over there. + */ +const moves = new SvelteMap() + /** * Draft keys whose last save threw (network / 5xx) → extracted error * message. Cleared on the next success. Drives the AutosaveIndicator's @@ -294,6 +308,14 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise { force: opts.force ?? false } }) + if (resp.status === 'moved') { + // Nothing was written. Like a conflict, `lastSync` stays put so the + // state survives every retry until the user acts on it. + if (resp.moved_to) { + moves.set(key, { movedTo: resp.moved_to, movedBy: resp.moved_by }) + } + return + } if (resp.status === 'conflict') { // Someone advanced the row past our `last_sync`. Park the // snapshot for the UI; do NOT touch `lastSync` — the next save @@ -317,6 +339,7 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise { // free instead of maintaining a separate source of truth. setLocalDraftHint(opts.workspace, opts.itemKind, opts.path, opts.value !== null) conflicts.delete(key) + moves.delete(key) failures.delete(key) // Clear pending only if it's still the opts we just saved — a // newer `save()` that arrived during the POST replaces the entry @@ -537,6 +560,7 @@ export const UserDraftDbSyncer = { } // Back in sync with the server: clear any conflict / failure. conflicts.delete(key) + moves.delete(key) failures.delete(key) }, @@ -614,6 +638,22 @@ export const UserDraftDbSyncer = { conflicts.delete(draftKey(query.workspace, query.itemKind, query.path)) }, + /** Reactive "the item moved away from this path" snapshot, if any. */ + getMove(query: UserDraftLastSyncQuery): { + readonly move: DraftMovedInfo | undefined + } { + const key = draftKey(query.workspace, query.itemKind, query.path) + return { + get move() { + return moves.get(key) + } + } + }, + + clearMove(query: UserDraftLastSyncQuery): void { + moves.delete(draftKey(query.workspace, query.itemKind, query.path)) + }, + /** * Force-save: bypass the `last_sync` check and overwrite the server row * (conflict modal's "Overwrite the remote"). Resolves once the key's save diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index f363aa40c0..c9519f00e8 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -106,6 +106,10 @@ * (our draft is behind the latest deploy). Cleared between loads to re-fire. */ let draftSavedAt = $state(undefined) let deployedAt = $state(undefined) + /** Hash the draft forked from, and the deployed head — the script equivalent + * of the flow/app version pair. */ + let draftBaseHash = $state(undefined) + let deployedHeadHash = $state(undefined) // Remounts ScriptBuilder on nav: false while a reload runs, true once data is // ready. A synchronous `{#key}` swap instead races Monaco's init against the @@ -150,6 +154,8 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + draftBaseHash = undefined + deployedHeadHash = undefined // Brand-new script: no deployed baseline, so never discard-on-equal. deployedBaseline = undefined const templatePath = page.url.searchParams.get('template') @@ -323,6 +329,11 @@ // field level: the draft supplies editor state (content, summary, …), // the deployed supplies metadata it lacks (hash, version markers). const { draft: draftFromBackend, ...deployedScript } = backendScript as any + // Exact staleness, preferred over the timestamps: a draft carried across + // a move keeps its old save time while the move mints a fresh deploy, so + // the timestamps alone would call every carried draft stale. + draftBaseHash = draftFromBackend?.parent_hash as string | undefined + deployedHeadHash = backendScript.hash as string | undefined const effectiveScript: EditableScript = draftFromBackend ? { ...deployedScript, ...draftFromBackend } : (deployedScript as EditableScript) @@ -464,6 +475,8 @@ bind:othersModalOpen {draftSavedAt} {deployedAt} + draftBaseVersion={draftBaseHash} + deployedHeadVersion={deployedHeadHash} onLoadLatestDeploy={async () => { // stopSync-bracketed; see restoreDeployed for the race. if (!$workspaceStore) return