feat: keep a Hub project live while an update is under review (#10814)

* feat: keep a Hub project live while an update is under review

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* fix: confirm before discarding a Hub update and document the route

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* fix: bind the discard confirmation to the session that opened it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* fix: keep the old wording against a Hub without pending updates

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* fix: say what the review lock actually blocks, in one alert

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* feat: let a publisher cancel a Hub submission from the wizard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* fix: hide the cancel action on a Hub that cannot withdraw

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* docs: describe startNewDraft for both Hub versions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* feat: warn when an update carries the published pipeline replay

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* fix: base the stale-replay warning on changed content, not recordings

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* fix: stop the stale-replay warning leaking across updates

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* fix: clear the captured cascade when starting another update

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

* fix: abandon an in-flight cascade when starting another update

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018At6NKGa6cQP1zakMS686d

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-08-26 20:47:24 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent e38c449007
commit c04b570574
4 changed files with 328 additions and 24 deletions
+62
View File
@@ -25287,6 +25287,68 @@ paths:
schema:
type: string
/w/{workspace}/hub/projects/{slug}/withdraw:
post:
summary: take a hub project submission back out of review
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body. Everything pushed for the
submission is kept, so it can be fixed and submitted again.
operationId: withdrawHubProject
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: slug
in: path
required: true
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
schema:
type: string
minLength: 3
maxLength: 50
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
- $ref: "#/components/parameters/HubPublishFolder"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/projects/{slug}/discard_update:
post:
summary: discard the pending update to a published hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body. The published project is
left untouched.
operationId: discardHubProjectUpdate
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: slug
in: path
required: true
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
schema:
type: string
minLength: 3
maxLength: 50
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
- $ref: "#/components/parameters/HubPublishFolder"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/project:
get:
summary: get the hub project linked to a workspace folder
+34 -3
View File
@@ -42,6 +42,11 @@ pub fn workspaced_service() -> Router {
.route("/migrations", post(publish_migrations))
.route("/projects/{slug}/export", get(get_project_export))
.route("/projects/{slug}/submit", post(submit_project))
.route("/projects/{slug}/withdraw", post(withdraw_project))
.route(
"/projects/{slug}/discard_update",
post(discard_project_update),
)
.route("/project", get(get_project_by_source))
}
@@ -554,9 +559,35 @@ async fn submit_project(
.await
}
// The Hub has no auth of its own: it validates bearer tokens by calling this
// instance's /api/users/whoami. Forwarding the caller's own token logs them in
// on the Hub as themselves (account auto-created on first use).
// Take a submission back out of review, keeping what was pushed for it.
async fn withdraw_project(
ctx: HubPublishCtx,
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
) -> Result<impl IntoResponse, Error> {
ctx.post(
&format!("/projects/{}/withdraw", slug),
&serde_json::json!({}),
)
.await
}
// Throw away the pending update to an already-published project. The published
// version is untouched — it never saw the update.
async fn discard_project_update(
ctx: HubPublishCtx,
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
) -> Result<impl IntoResponse, Error> {
ctx.post(
&format!("/projects/{}/discard_update", slug),
&serde_json::json!({}),
)
.await
}
// The Hub has no auth of its own: it validates bearer tokens by calling
// /api/users/whoami — on app.windmill.dev for the public Hub, on the paired
// instance for a private one. Forwarding the caller's own token logs them in on
// the Hub as themselves (account auto-created on first use).
async fn get_from_hub(
path: &str,
source_id: &str,
@@ -1,5 +1,5 @@
<script lang="ts">
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
import { Alert, Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
import WorkspaceDeployLayout from '$lib/components/WorkspaceDeployLayout.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -13,7 +13,8 @@
canRecordSession,
sanitizeSlug,
isValidSlug,
type DeployItem
type DeployItem,
type DeployToHubSession
} from './deployToHubSession.svelte'
import { TRIGGER_KINDS, triggerDetails } from '$lib/components/triggers/workspaceTriggersList'
import Toggle from '../Toggle.svelte'
@@ -40,6 +41,7 @@
Zap
} from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import Popover from '$lib/components/Popover.svelte'
// Folder name (no `f/`) this project is scoped to; provided by the /folders launcher.
@@ -61,6 +63,12 @@
// Inline pipeline graph above the item list, collapsed by default so the
// selection list stays the first thing in view.
let pipelineGraphOpen = $state(false)
// Discarding throws away every item pushed for the update and any recording made
// for it, none of which can be recovered. The session that opened the dialog is
// held rather than a boolean: a workspace or folder switch (browser history, say)
// replaces the session underneath an open dialog, and confirming must never
// discard a different folder's update.
let discardTarget = $state<DeployToHubSession | undefined>(undefined)
let resourceDrawer = $state<Drawer | undefined>()
let triggerDrawer = $state<Drawer | undefined>()
let bundleDrawer = $state<Drawer | undefined>()
@@ -146,6 +154,22 @@
</script>
{#if deployHub.session}
<ConfirmationModal
open={discardTarget !== undefined}
title="Discard update"
confirmationText="Discard"
onConfirmed={async () => {
const target = discardTarget
discardTarget = undefined
if (target && target === deployHub.session) await target.discardUpdate()
}}
onCanceled={() => (discardTarget = undefined)}
>
<span>
Discard this update? Everything pushed for it, including recordings made for it, is deleted
and cannot be recovered. Your published project is unaffected.
</span>
</ConfirmationModal>
{#key deployHub.session}
{@const s = deployHub.session}
<div>
@@ -178,8 +202,10 @@
</span>
<li class={stepNum === 1 ? 'text-primary' : stepNum > 1 ? 'opacity-60' : ''}>
<span class="font-mono text-emphasis">{stepNum > 1 ? '✓' : '1.'}</span>
<span class="font-semibold text-primary">Bundle your project</span>creates a draft
on the Hub with every selected script, flow, app and resource from this folder.
<span class="font-semibold text-primary">Bundle your project</span>sends every
selected script, flow, app and resource from this folder to the Hub{s.liveOnHub
? ' as an update'
: ' as a draft'}.
</li>
<li
class={stepNum === 2 ? 'text-primary' : stepNum > 2 ? 'opacity-60' : 'opacity-40'}
@@ -235,7 +261,7 @@
startIcon={{ icon: Cloud }}
onclick={openBundle}
>
Create Hub draft ({s.selectedItems.length})
{s.liveOnHub ? 'Bundle update' : 'Create Hub draft'} ({s.selectedItems.length})
</Button>
{:else if s.phase === 'draft'}
<Button
@@ -247,6 +273,17 @@
Submit for review
</Button>
{:else if s.phase === 'under_review'}
{#if s.hubSupportsUpdates}
<Button
variant="default"
unifiedSize="sm"
loading={s.withdrawing}
startIcon={{ icon: X }}
onclick={s.cancelSubmission}
>
Cancel submission
</Button>
{/if}
<Button
size="xs"
variant="subtle"
@@ -262,7 +299,19 @@
startIcon={{ icon: RotateCcw }}
onclick={s.startNewDraft}
>
New draft
{s.liveOnHub ? 'Publish an update' : 'New draft'}
</Button>
{/if}
{#if s.liveOnHub && s.phase === 'draft'}
<Button
variant="default"
destructive
unifiedSize="sm"
loading={s.discardingUpdate}
startIcon={{ icon: X }}
onclick={() => (discardTarget = s)}
>
Discard update
</Button>
{/if}
</div>
@@ -270,8 +319,10 @@
{#if s.phase === 'predeploy'}
<div class="flex flex-col gap-1 pb-3">
<span class="text-xs text-secondary">
Bundling creates a draft project on the Hub from the selected scripts, flows and
apps of <span class="font-mono">{s.selectedFolder}/</span>.
Bundling creates {s.liveOnHub
? 'an update to your Hub project'
: 'a draft project on the Hub'} from the selected scripts, flows and apps of
<span class="font-mono">{s.selectedFolder}/</span>.
{s.selectedItems.length} of {s.filteredWorkspaceItems.length} items selected.
</span>
</div>
@@ -351,6 +402,31 @@
{/if}
</div>
{/if}
{#if s.liveOnHub && s.phase !== 'live' && s.phase !== 'under_review'}
<Alert
type="info"
size="xs"
title={s.phase === 'predeploy'
? 'Your published project stays live'
: 'This is an update — your published project is still live'}
>
Visitors keep seeing the published version, with its stars, forks and comments,
until this update is approved. Approving replaces it in place; discarding leaves it
exactly as it is.
</Alert>
{/if}
{#if s.pipelineReplayMayBeStale && s.phase === 'draft'}
<Alert type="warning" size="xs" title="The data pipeline replay is the published one">
This update carries the cascade recorded for the version that is live, and at least
one item has changed since. Record it again below, or visitors will replay the old
run as though it were this version.
</Alert>
{/if}
{#if s.rejectionReason && s.phase === 'draft'}
<Alert type="error" size="xs" title="Changes requested">
{s.rejectionReason}
</Alert>
{/if}
{#if s.phase === 'draft'}
<div class="flex flex-col gap-1 pb-3">
<span class="text-xs text-secondary">
@@ -477,18 +553,21 @@
</div>
{/if}
{#if s.phase === 'under_review'}
<div
class="flex items-start gap-2 rounded-md border border-blue-300 bg-blue-50 p-3 text-xs text-blue-900 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-100"
<Alert
type="info"
size="xs"
title={s.liveOnHub
? 'Update under review — your published project is still live'
: 'Under review'}
>
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
<div class="flex flex-col gap-1">
<span class="font-semibold">Locked while under review</span>
<span>
The Windmill team is reviewing this submission. Editing, recording, and sharing
actions are disabled. Estimated turnaround: 1-2 business days.
</span>
</div>
</div>
The Windmill team is reviewing your project. Submission is locked until they answer
— no new version can be sent to the Hub, and no recording added to this one.
Estimated turnaround: 1-2 business days{#if s.hubSupportsUpdates}; cancel the
submission to get back to it sooner{/if}.{#if s.liveOnHub}
Visitors keep seeing the published version meanwhile, with its stars, forks and
comments; approving replaces it in place.{/if} Your folder itself is untouched — keep
editing your scripts and flows as usual.
</Alert>
{/if}
{#if s.phase === 'draft'}
{@const recordedCount = s.recordableItems.filter((i) => i.rec === 'recorded').length}
@@ -668,7 +747,11 @@
Waiting for the Windmill team to review the submission.
</span>
{:else}
<span class="text-[11px] text-hint"> Iterate further by starting a new draft. </span>
<span class="text-[11px] text-hint">
{s.liveOnHub
? 'Publish an update to change it — this stays live until the update is approved.'
: 'Iterate further by starting a new draft.'}
</span>
{/if}
</div>
{/snippet}
@@ -198,8 +198,31 @@ export class DeployToHubSession {
// Whether the Hub currently has a custom logo for this project (from
// rehydration) — drives the "Remove current logo" affordance.
hubHasRemoteLogo = $state(false)
// A pipeline recording is attached on the Hub. An update inherits the published
// one, which is only a demo of the new version if nothing it runs changed.
hubHasPipelineRecording = $state(false)
// The Hub's own verdict: this update runs different content from the published
// version. False when there is no update in flight.
hubItemsChanged = $state(false)
// The attached pipeline recording is the published version's, copied when this
// update started, rather than one recorded for it. Authoritative across reloads,
// unlike `pipelineRecorded`, which only remembers this session.
hubPipelineRecordingInherited = $state(false)
effectiveSlug = $state('')
hubItemIds = $state<Record<string, number>>({})
// Set once the project is published: everything the wizard shows from here on
// describes an update to it, and the published version keeps serving until that
// update is approved. `phase` is the update's own status, not the project's.
liveOnHub = $state(false)
/** This Hub knows about pending updates it answers rehydration with a `live`
* key. An older one takes a project offline to republish and has neither the
* withdraw nor the discard endpoint, so the actions built on them stay hidden. */
hubSupportsUpdates = $state(false)
// A reviewer's verdict on the current draft, shown so the publisher knows what
// to fix before resubmitting.
rejectionReason = $state<string | undefined>(undefined)
discardingUpdate = $state(false)
withdrawing = $state(false)
// Best-effort data table migrations for the bundle, editable in the drawer and
// pushed on deploy. Regenerated when the bundle drawer opens.
@@ -228,6 +251,10 @@ export class DeployToHubSession {
submitting = $state(false)
syncing = $state(false)
// Set from the Hub's answer to the draft request: this push went into an update
// rather than over the published project.
#publishedAsUpdate = false
// Intra-session tokens: latest call wins among competing calls on this session.
#triggerLoadTok = 0
#recordRunTok = 0
@@ -316,6 +343,18 @@ export class DeployToHubSession {
)
pipelineScriptPathSet = $derived(new Set(this.pipelineScriptPaths))
isPipelineProject = $derived(this.pipelineScriptPaths.length > 0)
/** The pipeline replay this update carries came from the published version, and
* something it runs has changed since so it is a recording of another version.
* `hubItemsChanged` is the Hub comparing content, not a guess from which items
* carry recordings: an item nobody ever recorded has not changed. */
pipelineReplayMayBeStale = $derived(
this.liveOnHub &&
this.isPipelineProject &&
this.hubHasPipelineRecording &&
this.hubPipelineRecordingInherited &&
this.hubItemsChanged &&
!this.pipelineRecorded
)
hubSlug = $derived(this.effectiveSlug || sanitizeSlug(this.hubName))
relevantTriggers = $derived.by(() => {
@@ -573,6 +612,17 @@ export class DeployToHubSession {
this.hubSummary = p.summary ?? ''
this.hubReadme = p.readme ?? ''
this.hubHasRemoteLogo = p.has_logo === true
this.hubHasPipelineRecording = p.has_pipeline_recording === true
this.hubItemsChanged = p.items_changed === true
this.hubPipelineRecordingInherited = p.pipeline_recording_inherited === true
this.rejectionReason = p.rejection_reason ?? undefined
// `live` is a key this Hub always sends — null unless an update is in
// flight, in which case the fields above describe that update and the
// project itself is still published. Its absence means a Hub old enough to
// still take a project offline while it re-publishes, so the wizard must
// not promise otherwise.
this.hubSupportsUpdates = 'live' in p
this.liveOnHub = this.hubSupportsUpdates && (p.live?.approved === true || p.status === 'live')
this.phase =
p.status === 'live' ? 'live' : p.status === 'under_review' ? 'under_review' : 'draft'
const ids: Record<string, number> = {}
@@ -898,6 +948,9 @@ export class DeployToHubSession {
try {
const parsed = JSON.parse(text)
if (typeof parsed?.slug === 'string') returnedSlug = parsed.slug
// The Hub decides this: publishing over an approved project goes into a
// pending update instead, and the project keeps serving meanwhile.
this.#publishedAsUpdate = parsed?.pending_revision === true
} catch {}
if (!returnedSlug) {
sendUserToast(`Hub did not return a slug. Aborting publish to avoid path drift.`, true)
@@ -1252,8 +1305,13 @@ export class DeployToHubSession {
// UI stuck in `predeploy`; rehydrate then upgrades to authoritative state.
this.draftItems = itemsSnapshot.map((i) => ({ ...i, rec: 'none' }))
this.phase = 'draft'
const asUpdate = this.#publishedAsUpdate
await this.rehydrateFromHub()
sendUserToast(`Draft created on the Hub. Add recordings before submitting for review.`)
sendUserToast(
asUpdate
? `Update ready on the Hub. Your published project stays live until it is approved.`
: `Draft created on the Hub. Add recordings before submitting for review.`
)
} finally {
this.deploying = false
}
@@ -1313,12 +1371,82 @@ export class DeployToHubSession {
}
}
/** Go back to picking items, to publish again. Local only nothing reaches the
* Hub until the bundle is confirmed, and where the Hub supports updates the
* published version keeps serving even then. */
startNewDraft = () => {
this.draftItems = []
this.recordings = {}
this.rejectionReason = undefined
// All of it belongs to the update just finished, not the one starting. The
// captured cascade especially: left in place, the next update could save a
// replay of the version it replaces. Bumping the token first abandons a run
// still in flight, which would otherwise write its result back over this.
this.#pipelineRunTok++
this.pipelineRecorded = false
this.pipelineRecordingResult = undefined
this.pipelineRunState = 'idle'
this.pipelineRunError = undefined
this.phase = 'predeploy'
}
/** Take the submission back out of review. Everything pushed for it is kept, so
* it can be fixed and submitted again. */
cancelSubmission = async () => {
if (this.withdrawing) return
const slug = this.effectiveSlug
if (!slug) return
this.withdrawing = true
try {
const res = await fetch(
`/api/w/${this.workspace}/hub/projects/${encodeURIComponent(slug)}/withdraw${this.#folderQs()}`,
{ method: 'POST', credentials: 'include' }
)
if (!res.ok) {
sendUserToast(`Could not cancel the submission: ${await res.text()}`, true)
return
}
if (this.#disposed) return
this.phase = 'draft'
await this.rehydrateFromHub()
sendUserToast(`Submission cancelled. Everything you pushed is still here.`)
} catch (e: any) {
sendUserToast(`Could not cancel the submission: ${e?.message ?? e}`, true)
} finally {
this.withdrawing = false
}
}
/** Throw away an update in progress and go back to what is published. */
discardUpdate = async () => {
if (this.discardingUpdate) return
const slug = this.effectiveSlug
if (!slug) return
this.discardingUpdate = true
try {
const res = await fetch(
`/api/w/${this.workspace}/hub/projects/${encodeURIComponent(slug)}/discard_update${this.#folderQs()}`,
{ method: 'POST', credentials: 'include' }
)
if (!res.ok) {
sendUserToast(`Could not discard the update: ${await res.text()}`, true)
return
}
if (this.#disposed) return
this.draftItems = []
this.recordings = {}
this.deploymentStatus = {}
this.rejectionReason = undefined
this.phase = 'live'
await this.rehydrateFromHub()
sendUserToast(`Update discarded. The published project is unchanged.`)
} catch (e: any) {
sendUserToast(`Could not discard the update: ${e?.message ?? e}`, true)
} finally {
this.discardingUpdate = false
}
}
/** Reset record-drawer state and load the target's schema. */
async openRecord(it: DeployItem) {
const tok = ++this.#recordRunTok