feat: support hub flows in raw app runnables (#8627)

* feat: support hub flows in raw app runnables

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: support hub flow previews in app ui

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: move trigger context into flow graph viewer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use script viewer for hub flow steps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: stretch raw app flow previews to pane height

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: improve hub flow run links

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: stabilize hub flow preview drawer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: align hub flow id validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix runnable panel indentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-03-31 20:26:56 +02:00
committed by GitHub
parent 6c3c971af5
commit 040a199685
17 changed files with 440 additions and 118 deletions

View File

@@ -8,6 +8,8 @@
pub use windmill_types::flows::*;
use anyhow::Context;
use serde::Deserialize;
use serde::Serialize;
use sqlx::types::Json;
use sqlx::types::JsonRawValue;
@@ -15,10 +17,89 @@ use sqlx::types::JsonRawValue;
use crate::{
cache::{self, FlowExtras},
db::DB,
error::Error,
error::{to_anyhow, Error},
utils::{http_get_from_hub, StripPath},
worker::{to_raw_value, Connection},
DEFAULT_HUB_BASE_URL, HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION,
};
#[derive(Deserialize)]
pub struct HubFlow {
pub value: FlowValue,
}
#[derive(Deserialize)]
struct HubFlowResponse {
flow: HubFlow,
}
fn extract_hub_flow_id_from_path(path: &str) -> Result<i32, Error> {
let hub_flow_path = path.strip_prefix("hub/flows/").ok_or_else(|| {
Error::BadRequest(format!(
"expected hub flow path to start with hub/flows/ (got {path})"
))
})?;
let flow_id = hub_flow_path
.split('/')
.next()
.filter(|segment| !segment.is_empty())
.ok_or_else(|| {
Error::BadRequest(format!(
"expected hub flow path to include a numeric id after hub/flows/ (got {path})"
))
})?;
let flow_id = flow_id.parse::<i32>().map_err(|_| {
Error::BadRequest(format!(
"expected hub flow path to include a numeric id after hub/flows/ (got {path})"
))
})?;
if flow_id <= 0 {
return Err(Error::BadRequest(format!(
"expected hub flow path to include a positive numeric id after hub/flows/ (got {path})"
)));
}
Ok(flow_id)
}
pub async fn get_full_hub_flow_by_path(
path: StripPath,
http_client: &reqwest::Client,
db: Option<&DB>,
) -> crate::error::Result<HubFlow> {
let path = path.to_path();
let flow_id = extract_hub_flow_id_from_path(&path)?;
let hub_base_url = HUB_BASE_URL.read().await.clone();
let hub_url = format!("{hub_base_url}/flows/{flow_id}/json");
let response = match http_get_from_hub(http_client, &hub_url, false, None, db)
.await?
.error_for_status()
.map_err(to_anyhow)
{
Ok(response) => response,
Err(_) if hub_base_url != DEFAULT_HUB_BASE_URL && flow_id < PRIVATE_HUB_MIN_VERSION =>
{
tracing::info!("Not found on private hub, fallback to default hub for hub flow {path}");
let fallback_url = format!("{DEFAULT_HUB_BASE_URL}/flows/{flow_id}/json");
http_get_from_hub(http_client, &fallback_url, false, None, db)
.await?
.error_for_status()
.map_err(to_anyhow)?
}
Err(err) => return Err(err.into()),
};
Ok(response
.json::<HubFlowResponse>()
.await
.context(format!("Decoding hub response for flow at path {path}"))?
.flow)
}
/// Serialize-only wrapper that combines resolved FlowValue with display-only extras.
/// flatten + RawValue is fine for serialization (only deserialization breaks).
#[derive(Serialize)]
@@ -228,4 +309,36 @@ mod tests {
assert!(!output.contains("notes"));
assert!(!output.contains("groups"));
}
#[test]
fn extract_hub_flow_id_accepts_id_only_paths() {
assert_eq!(extract_hub_flow_id_from_path("hub/flows/76").unwrap(), 76);
}
#[test]
fn extract_hub_flow_id_accepts_id_and_slug_paths() {
assert_eq!(
extract_hub_flow_id_from_path("hub/flows/76/send-message-to-company-ai-assistant")
.unwrap(),
76
);
}
#[test]
fn extract_hub_flow_id_rejects_non_numeric_ids() {
let err = extract_hub_flow_id_from_path("hub/flows/send_message").unwrap_err();
assert!(matches!(err, Error::BadRequest(_)));
}
#[test]
fn extract_hub_flow_id_rejects_missing_ids() {
let err = extract_hub_flow_id_from_path("hub/flows/").unwrap_err();
assert!(matches!(err, Error::BadRequest(_)));
}
#[test]
fn extract_hub_flow_id_rejects_zero_ids() {
let err = extract_hub_flow_id_from_path("hub/flows/0").unwrap_err();
assert!(matches!(err, Error::BadRequest(_)));
}
}

View File

@@ -14,6 +14,7 @@ use crate::{
client::AuthedClient,
db::{AuthedRef, UserDbWithAuthed, DB},
error::{self, to_anyhow, Error},
flows::get_full_hub_flow_by_path,
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
users::username_to_permissioned_as,
@@ -154,15 +155,31 @@ pub async fn get_payload_tag_from_prefixed_path(
.await?
} else if path.starts_with("flow/") {
let path = path.strip_prefix("flow/").unwrap().to_string();
let FlowVersionInfo { dedicated_worker, tag, version, .. } =
get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?;
(
JobPayload::Flow { path, dedicated_worker, apply_preprocessor: false, version },
tag,
None,
None,
None,
)
if path.starts_with("hub/flows/") {
let hub_flow =
get_full_hub_flow_by_path(StripPath(path.clone()), &HTTP_CLIENT, Some(db)).await?;
(
JobPayload::RawFlow {
value: hub_flow.value,
path: Some(path),
restarted_from: None,
},
None,
None,
None,
None,
)
} else {
let FlowVersionInfo { dedicated_worker, tag, version, .. } =
get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?;
(
JobPayload::Flow { path, dedicated_worker, apply_preprocessor: false, version },
tag,
None,
None,
None,
)
}
} else {
return Err(Error::BadRequest(format!(
"path must start with script/ or flow/ (got {})",

View File

@@ -1,7 +1,10 @@
<script lang="ts">
import type { FlowModule, FlowValue } from '$lib/gen'
import type { FlowModule, FlowValue, TriggersCount } from '$lib/gen'
import type { TriggerContext } from '$lib/components/triggers'
import { Triggers } from '$lib/components/triggers/triggers.svelte'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, hasContext, setContext } from 'svelte'
import { writable } from 'svelte/store'
import { twMerge } from 'tailwind-merge'
import FlowGraphViewerStep from './FlowGraphViewerStep.svelte'
@@ -27,6 +30,8 @@
minHeight?: number
noBorder?: boolean
hideDefaultInputs?: boolean
provideTriggerContext?: boolean
fillAvailableHeight?: boolean
}
let {
@@ -40,18 +45,32 @@
workspace = $workspaceStore,
minHeight = 400,
noBorder = false,
hideDefaultInputs = false
hideDefaultInputs = false,
provideTriggerContext = false,
fillAvailableHeight = false
}: Props = $props()
let availableHeight = $state(0)
if (provideTriggerContext && !hasContext('TriggerContext')) {
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState: new Triggers()
})
}
const dispatch = createEventDispatcher()
</script>
<div class="grid grid-cols-3 w-full h-full">
<div bind:clientHeight={availableHeight} class="grid grid-cols-3 w-full h-full min-h-0">
{#if !noGraph}
<div
class="{noSide || (hideDefaultInputs && stepDetail == undefined)
? 'col-span-3'
: 'sm:col-span-2 col-span-3'} w-full max-h-full"
: 'sm:col-span-2 col-span-3'} w-full h-full min-h-0 max-h-full"
class:overflow-auto={overflowAuto}
class:border={!noBorder}
>
@@ -61,7 +80,7 @@
cache={flow.value.cache_ttl !== undefined}
path={flow?.path}
{download}
{minHeight}
minHeight={fillAvailableHeight ? Math.max(minHeight, availableHeight) : minHeight}
{workspace}
modules={flow?.value?.modules}
failureModule={flow?.value?.failure_module}
@@ -88,7 +107,9 @@
{#if !noSide && !(hideDefaultInputs && stepDetail == undefined)}
<div
class={twMerge(
'relative w-full h-full min-h-[150px] max-h-[90vh] border-r border-b border-t p-2 pt-0 overflow-auto hidden sm:flex flex-col gap-4',
fillAvailableHeight
? 'relative w-full h-full min-h-0 border-r border-b border-t p-2 pt-0 overflow-auto hidden sm:flex flex-col gap-4'
: 'relative w-full h-full min-h-[150px] max-h-[90vh] border-r border-b border-t p-2 pt-0 overflow-auto hidden sm:flex flex-col gap-4',
noGraph ? 'border-0 w-max' : ''
)}
>

View File

@@ -51,17 +51,10 @@
<InputTransformsViewer inputTransforms={stepDetail?.value?.input_transforms ?? {}} />
</div>
{#if stepDetail.value.path.startsWith('hub/')}
<div class="mt-6">
<h3 class="mb-1 mt-6 text-xs font-semibold text-emphasis">Code</h3>
<iframe
class="w-full h-full text-sm"
title="embedded script from hub"
frameborder="0"
src="{$hubBaseUrlStore}/embed/script/{stepDetail.value?.path?.substring(4)}"
></iframe>
</div>
{/if}
<div class="mt-6">
<h3 class="mb-1 mt-6 text-xs font-semibold text-emphasis">Code</h3>
<FlowModuleScript path={stepDetail.value.path} hash={jobScriptHash} />
</div>
{:else if stepDetail.value.type == 'rawscript'}
<div class="text-2xs mb-4 mt-2">
<h3 class="mb-1 text-xs font-semibold text-emphasis">Step inputs</h3>
@@ -218,27 +211,16 @@
<InputTransformsViewer inputTransforms={stepDetail?.value?.input_transforms ?? {}} />
</div>
{/if}
{#if stepDetail.value.path.startsWith('hub/')}
<div class="flex flex-col grow">
<div class="mb-1 mt-6 flex justify-between items-center">
<h3 class="font-semibold text-xs text-emphasis">Code</h3>
<Button
unifiedSize="sm"
variant="subtle"
onClick={codeViewer?.openDrawer}
startIcon={{ icon: Expand }}>Expand</Button
>
</div>
<iframe
class="w-full grow text-sm h-full"
title="embedded script from hub"
frameborder="0"
src="{$hubBaseUrlStore}/embed/script/{stepDetail.value?.path?.substring(4)}"
></iframe>
</div>
{:else}
<FlowModuleScript path={stepDetail.value.path} hash={jobScriptHash} />
{/if}
<div class="mb-1 mt-6 flex justify-between items-center">
<h3 class="font-semibold text-xs text-emphasis">Code</h3>
<Button
unifiedSize="sm"
variant="subtle"
onClick={codeViewer?.openDrawer}
startIcon={{ icon: Expand }}>Expand</Button
>
</div>
<FlowModuleScript path={stepDetail.value.path} hash={jobScriptHash} />
{:else if stepDetail.value.type == 'aiagent'}
<div class="text-xs">
<h3 class="mb-1 font-semibold mt-2 text-xs text-emphasis">Step inputs</h3>

View File

@@ -2,6 +2,7 @@
import { type Job } from '$lib/gen'
import { base } from '$lib/base'
import JobStatus from '$lib/components/JobStatus.svelte'
import { flowPathToHref } from '$lib/scripts'
import { displayDate, truncateRev } from '$lib/utils'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import TimeAgo from './TimeAgo.svelte'
@@ -94,7 +95,9 @@
{#if (job && job.job_kind == 'flow') || job?.job_kind == 'script'}
{@const stem = `${job?.job_kind}s`}
{@const isScript = job?.job_kind === 'script'}
{@const viewHref = `${base}/${stem}/get/${isScript ? job?.script_hash : job?.script_path}`}
{@const viewHref = isScript
? `${base}/${stem}/get/${job?.script_hash}`
: flowPathToHref(job?.script_path ?? '')}
<div class="flex flex-row gap-2 items-center">
{#if isScript}
<Code2 size={SMALL_ICON_SIZE} class="min-w-3.5" />

View File

@@ -4,9 +4,11 @@
const bubble = createBubbler()
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import { base } from '$lib/base'
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import FlowModuleScript from '$lib/components/flows/content/FlowModuleScript.svelte'
import FlowPathViewer from '$lib/components/flows/content/FlowPathViewer.svelte'
import { emptySchema, sendUserToast } from '$lib/utils'
import { emptySchema, getHubFlowIdFromPath, isHubFlowPath, sendUserToast } from '$lib/utils'
import { getContext, tick, untrack } from 'svelte'
import type {
ConnectedAppInput,
@@ -31,7 +33,8 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ScriptEditorDrawer from '$lib/components/flows/content/ScriptEditorDrawer.svelte'
import FlowEditorDrawer from '$lib/components/flows/content/FlowEditorDrawer.svelte'
import { ScriptService } from '$lib/gen'
import { FlowService, ScriptService, type OpenFlow } from '$lib/gen'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
interface Props {
runnable: RunnableByPath
@@ -43,6 +46,7 @@
isLoading?: boolean
onRun?: any
onCancel?: any
hubFlowPreview?: OpenFlow | undefined
}
let {
@@ -52,14 +56,17 @@
rawApps = false,
isLoading = false,
onRun = async () => {},
onCancel = async () => {}
onCancel = async () => {},
hubFlowPreview = $bindable(undefined)
}: Props = $props()
const viewerContext = getContext<AppViewerContext>('AppViewerContext')
let drawerFlowViewer: Drawer | undefined = $state(undefined)
let flowPath: string = $state('')
let drawerShowsHubFlow = $state(false)
let notFound = $state(false)
let hubFlowId = $derived(getHubFlowIdFromPath(runnable.path))
// Key to force re-mounting of viewer components (bypasses FlowModuleScript cache)
let refreshKey = $state(0)
@@ -70,6 +77,7 @@
const dispatch = createEventDispatcher()
async function refreshScript(runnable: RunnableByPath) {
hubFlowPreview = undefined
try {
let { schema } = await getScriptByPath(runnable.path)
if (!deepEqual(runnable.schema, schema)) {
@@ -86,7 +94,39 @@
}
async function refreshFlow(runnable: RunnableByPath) {
hubFlowPreview = undefined
try {
const hubFlowId = getHubFlowIdFromPath(runnable.path)
if (hubFlowId !== undefined) {
const hub = await FlowService.getHubFlowById({ id: hubFlowId })
const flow = hub.flow ? structuredClone(hub.flow) : undefined
if (flow?.value.preprocessor_module?.value.type === 'rawscript') {
flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues(
String(hubFlowId),
flow.value.preprocessor_module.value.content
)
}
if (!flow) {
notFound = true
return
}
hubFlowPreview = flow
const schema =
flow.schema && typeof flow.schema === 'object' && Object.keys(flow.schema).length > 0
? (flow.schema as any)
: emptySchema()
if (!deepEqual(runnable.schema, schema)) {
runnable.schema = schema
if (!runnable.schema.order) {
runnable.schema.order = Object.keys(runnable.schema.properties ?? {})
}
fields = computeFields(schema, false, fields ?? {})
}
return
}
const { schema } =
(await loadSchema($workspaceStore ?? '', runnable.path, 'flow')) ?? emptySchema()
if (!deepEqual(runnable.schema, schema)) {
@@ -158,6 +198,8 @@
refreshScript(runnable)
} else if (runnable.runType == 'flow') {
refreshFlow(runnable)
} else {
hubFlowPreview = undefined
}
lastRunnable = runnable
}
@@ -170,8 +212,34 @@
</script>
<Drawer bind:this={drawerFlowViewer} size="1200px">
<DrawerContent title="Flow {flowPath}" on:close={drawerFlowViewer.closeDrawer}>
<FlowPathViewer path={flowPath ?? ''} />
<DrawerContent
title="Flow {flowPath}"
on:close={() => {
flowPath = ''
drawerShowsHubFlow = false
drawerFlowViewer?.closeDrawer()
}}
>
{#if drawerShowsHubFlow}
<div class="flex flex-col flex-1 h-full min-h-0 overflow-auto">
{#if hubFlowPreview}
<FlowGraphViewer
triggerNode
provideTriggerContext
fillAvailableHeight
flow={{ ...hubFlowPreview, path: flowPath }}
/>
{:else if notFound}
<div class="p-4 text-red-400">Hub flow not found at {flowPath}</div>
{:else}
<div class="p-4">
<Skeleton layout={[[40]]} />
</div>
{/if}
</div>
{:else if flowPath}
<FlowPathViewer path={flowPath} fillAvailableHeight />
{/if}
</DrawerContent>
</Drawer>
@@ -210,7 +278,7 @@
size="xs"
startIcon={{ icon: RefreshCw }}
on:click={async () => {
sendUserToast('Getting latest script version at that path')
sendUserToast('Getting latest runnable version at that path')
// Increment refreshKey to force re-mounting of viewer components (bypasses cache)
refreshKey++
lastRunnable = undefined
@@ -238,31 +306,45 @@
startIcon={{ icon: Eye }}
on:click={() => {
flowPath = runnable.path
drawerShowsHubFlow = isHubFlowPath(runnable.path)
drawerFlowViewer?.openDrawer()
}}
>
Expand
</Button>
<Button
variant="default"
size="xs"
startIcon={{ icon: Pen }}
on:click={() => {
openFlowEditor(runnable.path)
}}
>
Edit
</Button>
<Button
variant="default"
size="xs"
startIcon={{ icon: Eye }}
endIcon={{ icon: ExternalLink }}
target="_blank"
href="{base}/flows/get/{runnable.path}?workspace={$workspaceStore}"
>
Details
</Button>
{#if hubFlowId}
<Button
variant="default"
size="xs"
startIcon={{ icon: GitFork }}
endIcon={{ icon: ExternalLink }}
target="_blank"
href="{base}/flows/add?hub={hubFlowId}"
>
Fork
</Button>
{:else}
<Button
variant="default"
size="xs"
startIcon={{ icon: Pen }}
on:click={() => {
openFlowEditor(runnable.path)
}}
>
Edit
</Button>
<Button
variant="default"
size="xs"
startIcon={{ icon: Eye }}
endIcon={{ icon: ExternalLink }}
target="_blank"
href="{base}/flows/get/{runnable.path}?workspace={$workspaceStore}"
>
Details
</Button>
{/if}
{:else}
<Button
size="xs"
@@ -308,13 +390,20 @@
nonCaptureEvent={true}
btnClasses={'bg-surface text-primay hover:bg-hover'}
variant="default"
size="xs">Cache</Button
size="xs"
>
Cache
</Button>
{/snippet}
{#snippet content()}
Since this is a reference to a workspace {runnable.runType}, set the cache in the {runnable.runType}
settings directly by editing it. The cache will be shared by any app or flow that uses this
{runnable.runType}.
{#if runnable.runType == 'flow' && isHubFlowPath(runnable.path)}
Since this is a reference to a hub flow, cache settings are managed from the flow after
you fork it into your workspace.
{:else}
Since this is a reference to a workspace {runnable.runType}, set the cache in the
{runnable.runType} settings directly by editing it. The cache will be shared by any app or
flow that uses this {runnable.runType}.
{/if}
{/snippet}
</Popover>
@@ -325,18 +414,37 @@
class="!text-xs !rounded-xs"
/>
</div>
<div class="w-full grow overflow-y-auto">
<div class="w-full grow min-h-0 overflow-y-auto">
{#key `${viewerContext?.stateId ? get(viewerContext.stateId) : 0}-${refreshKey}`}
{#if notFound}
<div class="text-red-400"
>{runnable.runType} not found at {runnable.path} in workspace {$workspaceStore}</div
>
<div class="text-red-400">
{#if runnable.runType == 'flow' && isHubFlowPath(runnable.path)}
Hub flow not found at {runnable.path}
{:else}
{runnable.runType} not found at {runnable.path} in workspace {$workspaceStore}
{/if}
</div>
{:else if runnable.runType == 'script' || runnable.runType == 'hubscript'}
<div class="border">
<FlowModuleScript path={runnable.path} />
</div>
{:else if runnable.runType == 'flow'}
<FlowPathViewer path={runnable.path} />
{#if isHubFlowPath(runnable.path)}
{#if hubFlowPreview}
<div class="flex flex-col flex-1 h-full min-h-0 overflow-auto">
<FlowGraphViewer
triggerNode
provideTriggerContext
fillAvailableHeight
flow={{ ...hubFlowPreview, path: runnable.path }}
/>
</div>
{:else}
<Skeleton layout={[[40]]} />
{/if}
{:else}
<FlowPathViewer path={runnable.path} fillAvailableHeight />
{/if}
{:else}
Unrecognized runType {runnable.runType}
{/if}

View File

@@ -90,7 +90,9 @@
async function loadCode(path: string, hash: string | undefined) {
try {
notFound = false
const script = hash
const script = path.startsWith('hub/')
? await getScriptByPath(path!)
: hash
? await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash })
: await getScriptByPath(path!)
code = script.content

View File

@@ -13,9 +13,10 @@
interface Props {
path: string;
noSide?: boolean;
fillAvailableHeight?: boolean;
}
let { path, noSide = false }: Props = $props();
let { path, noSide = false, fillAvailableHeight = false }: Props = $props();
let flow: Flow | undefined = $state(undefined)
@@ -41,7 +42,7 @@
<div class="flex flex-col flex-1 h-full overflow-auto">
{#if flow}
<FlowGraphViewer triggerNode={true} {noSide} {flow} />
<FlowGraphViewer triggerNode={true} {noSide} {flow} {fillAvailableHeight} />
{:else}
<Skeleton layout={[[40]]} />
{/if}

View File

@@ -20,13 +20,15 @@
import SchemaForm from '../SchemaForm.svelte'
import RunnableJobPanelInner from '../apps/editor/RunnableJobPanelInner.svelte'
import JobLoader from '../JobLoader.svelte'
import type { Job, ScriptLang } from '$lib/gen'
import type { Job, OpenFlow, ScriptLang } from '$lib/gen'
import { slide } from 'svelte/transition'
import { DebugToolbar, DebugPanel, debugState } from '$lib/components/debug'
import LogViewer from '$lib/components/LogViewer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import RunButton from '$lib/components/RunButton.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { isHubFlowPath } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
type RunnableWithInlineScript = RunnableWithFields & {
inlineScript?: InlineScript & { language: ScriptLang }
@@ -70,6 +72,7 @@
let selectedTab = $state('test')
let args = $state({})
let hubFlowPreview: OpenFlow | undefined = $state(undefined)
function getSchema(runnable: RunnableWithFields) {
if (isRunnableByPath(runnable)) {
@@ -162,7 +165,15 @@
} else if (isRunnableByPath(runnable)) {
if (jobLoader && isRunnableByPath(runnable)) {
if (runnable.runType == 'flow') {
await jobLoader.runFlowByPath(runnable.path, args)
if (isHubFlowPath(runnable.path)) {
if (!hubFlowPreview) {
sendUserToast('Hub flow preview is still loading', true)
return
}
await jobLoader.runFlowPreview(args, hubFlowPreview, undefined, runnable.path)
} else {
await jobLoader.runFlowByPath(runnable.path, args)
}
} else if (runnable.runType == 'script' || runnable.runType == 'hubscript') {
await jobLoader.runScriptByPath(runnable.path, args)
}
@@ -203,6 +214,7 @@
rawApps
bind:runnable
bind:fields={runnable.fields}
bind:hubFlowPreview
on:fork={(e) => fork(e.detail)}
on:delete
{id}

View File

@@ -1,6 +1,7 @@
import type { Job } from '$lib/gen'
import { triggerIconMap } from '$lib/components/triggers/utils'
import { formatMemory } from '$lib/utils'
import { flowPathToHref } from '$lib/scripts'
import { Calendar, Bot } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
@@ -240,11 +241,12 @@ export const fieldConfigs: Record<JobField, FieldConfig> = {
field: 'script_path',
label: 'Path',
getValue: (job) => job.script_path || null,
getHref: (job, workspaceId) => {
getHref: (job, _workspaceId) => {
if (!job.script_path) return null
const stem = job.job_kind === 'script' ? 'scripts' : 'flows'
const isScript = job.job_kind === 'script'
return `/${stem}/get/${isScript ? job.script_hash : job.script_path}`
return isScript
? `/scripts/get/${job.script_hash}`
: flowPathToHref(job.script_path)
}
},

View File

@@ -14,6 +14,7 @@
import Button from '$lib/components/common/button/Button.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { getRelevantFields, getTriggerInfo, type FieldConfig } from './JobDetailFieldConfig'
import { flowPathToHref } from '$lib/scripts'
import { slide } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
@@ -385,10 +386,12 @@
<div class="flex flex-col gap-1 flex-1 min-w-0">
<!-- Title row -->
<div class="min-w-0 grow">
{#if job.script_path && (job.job_kind === 'script' || job.job_kind === 'flow' || job.job_kind === 'singlestepflow')}
{#if job.script_path && (job.job_kind === 'script' || job.job_kind === 'flow' || job.job_kind === 'singlestepflow' || job.job_kind === 'flowpreview')}
{@const stem = job.job_kind === 'script' ? 'scripts' : 'flows'}
{@const isScript = job.job_kind === 'script'}
{@const viewHref = `${base}/${stem}/get/${isScript ? job?.script_hash : job?.script_path}`}
{@const viewHref = isScript
? `${base}/${stem}/get/${job?.script_hash}`
: flowPathToHref(job?.script_path ?? '')}
<a
href={viewHref}
class="text-emphasis {compact

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte'
import { truncateHash } from '$lib/utils'
import { getJobKindDisplayLabel, truncateHash } from '$lib/utils'
import { base } from '$lib/base'
import { truncateRev } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
@@ -46,7 +46,7 @@
{/if}
{#if job && 'job_kind' in job}
<div>
<Badge color="gray" {large}>Job kind: {job.job_kind}</Badge>
<Badge color="gray" {large}>Job kind: {getJobKindDisplayLabel(job.job_kind, job.script_path)}</Badge>
</div>
{/if}
{#if job && job.flow_status && job.job_kind === 'script'}

View File

@@ -9,6 +9,7 @@
isScriptPreview,
msToReadableTime,
isFlowPreview,
getJobKindDisplayLabel,
getJobKindIcon
} from '$lib/utils'
import { Button } from '../common'
@@ -155,12 +156,12 @@
{/if}
<JobKindIcon size={14} />
</div>
{#snippet text()}
<span>
{#if job && job.job_kind}
{job.job_kind}
{/if}
{#if job && job.is_flow_step && job.parent_job}
{#snippet text()}
<span>
{#if job && job.job_kind}
{getJobKindDisplayLabel(job.job_kind, job.script_path)}
{/if}
{#if job && job.is_flow_step && job.parent_job}
<br /> Step of flow
<a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
{truncateRev(job.parent_job, 10)}

View File

@@ -2,7 +2,8 @@ import { get } from 'svelte/store'
import { base } from '$lib/base'
import type { Schema, SupportedLanguage } from './common'
import { FlowService, type Script, ScriptService, ScheduleService } from './gen'
import { workspaceStore } from './stores'
import { hubBaseUrlStore, workspaceStore } from './stores'
import { getHubFlowIdFromPath } from './utils'
export function scriptLangToEditorLang(
lang:
@@ -129,6 +130,15 @@ export function scriptPathToHref(path: string, hubBaseUrl: string): string {
}
}
export function flowPathToHref(path: string, hubBaseUrl: string = get(hubBaseUrlStore)): string {
if (path.startsWith('hub/flows/')) {
const hubFlowId = getHubFlowIdFromPath(path)
return hubFlowId ? `${hubBaseUrl}/flows/${hubFlowId}` : hubBaseUrl
}
return `${base}/flows/get/${path}?workspace=${get(workspaceStore)}`
}
const scriptLanguagesArray: [SupportedLanguage | 'docker' | 'bunnative', string][] = [
['bun', 'TypeScript (Bun)'],
['python3', 'Python'],

View File

@@ -1464,6 +1464,35 @@ export function isFlowPreview(job_kind: Job['job_kind'] | undefined) {
return !!job_kind && (job_kind === 'flowpreview' || job_kind === 'flownode')
}
export function isHubFlowPath(scriptPath: string | undefined | null) {
return !!scriptPath && scriptPath.startsWith('hub/flows/')
}
export function getHubFlowIdFromPath(scriptPath: string | undefined | null) {
if (!scriptPath || !isHubFlowPath(scriptPath)) {
return undefined
}
const hubFlowPath = scriptPath.substring('hub/flows/'.length)
const [idPart] = hubFlowPath.split('/')
const id = Number(idPart)
return Number.isInteger(id) && id > 0 ? id : undefined
}
export function getJobKindDisplayLabel(
jobKind: Job['job_kind'] | undefined,
scriptPath: string | undefined | null
) {
if (jobKind === 'script_hub') {
return 'Script from hub'
}
if (isFlowPreview(jobKind) && isHubFlowPath(scriptPath)) {
return 'Flow from hub'
}
return jobKind ?? ''
}
export function isNotFlow(job_kind: Job['job_kind'] | undefined) {
return job_kind !== 'flow' && job_kind !== 'singlestepflow' && !isFlowPreview(job_kind)
}

View File

@@ -153,7 +153,9 @@
} else if (hubId) {
const hub = await FlowService.getHubFlowById({ id: Number(hubId) })
delete hub['comments']
initialPath = `u/${$userStore?.username}/flow_${hubId}`
initialPath = `u/${$userStore?.username
.split('@')[0]
.replace(/[^a-zA-Z0-9_]/g, '')}/flow_${hubId}`
Object.assign(flow, hub.flow)
if (flow.value.preprocessor_module?.value.type === 'rawscript') {
flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues(

View File

@@ -17,6 +17,8 @@
copyToClipboard,
emptyString,
encodeState,
getHubFlowIdFromPath,
isHubFlowPath,
isFlowPreview,
isNotFlow,
isScriptPreview
@@ -295,6 +297,17 @@
function forkPreview() {
if (isFlowPreview(job?.job_kind)) {
if (isHubFlowPath(job?.script_path)) {
const hubFlowId = getHubFlowIdFromPath(job?.script_path)
if (hubFlowId === undefined) {
sendUserToast('Could not determine the hub flow to fork', true)
return
}
$initialArgsStore = job?.args
window.open(`/flows/add?hub=${hubFlowId}`)
return
}
const state = {
flow: { value: job?.raw_flow },
path: job?.script_path + '_fork',
@@ -492,9 +505,10 @@
{#snippet left()}
<h1 class="text-sm font-semibold text-primary">run/{page.params.run}</h1>
{/snippet}
{#snippet right()}
{@const isScript = job?.job_kind === 'script'}
{@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`}
{#snippet right()}
{@const isScript = job?.job_kind === 'script'}
{@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)}
{@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`}
{#if job && 'deleted' in job && !job?.deleted && ($superadmin || ($userStore?.is_admin ?? false))}
<Dropdown
items={[
@@ -547,16 +561,18 @@
</Dropdown>
</div>
{/if}
{#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)}
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: GitBranch }}
on:click={forkPreview}
>
Fork {isFlowPreview(job?.job_kind) ? 'flow' : 'code'} preview
</Button>
{/if}
{#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)}
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: GitBranch }}
on:click={forkPreview}
>
{isHubFlowPreview
? 'Fork flow into workspace'
: `Fork ${isFlowPreview(job?.job_kind) ? 'flow' : 'code'} preview`}
</Button>
{/if}
{#if persistentScriptDefinition !== undefined}
<Button
unifiedSize="md"