Fable review

This commit is contained in:
tristantr
2026-06-11 12:26:13 +02:00
parent c715a3f1f8
commit e674c6d766
3 changed files with 222 additions and 214 deletions
-8
View File
@@ -177,8 +177,6 @@ struct PublishFlowBody {
#[serde(skip_serializing_if = "Option::is_none")]
source_path: Option<String>,
project_slug: String,
#[serde(skip_serializing_if = "Option::is_none")]
recording: Option<serde_json::Value>,
}
async fn publish_flow(
@@ -223,8 +221,6 @@ struct PublishRawAppBody {
description: Option<String>,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
external_embed_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source_path: Option<String>,
@@ -307,8 +303,6 @@ struct PublishResourceTypeBody {
schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
app: Option<String>,
project_slug: String,
}
@@ -332,8 +326,6 @@ async fn publish_resource_type(
struct PublishResourceBody {
path: String,
resource_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
#[derive(Deserialize, Serialize)]
@@ -27,7 +27,7 @@
ScheduleService
} from '$lib/gen'
import { workspaceStore, hubBaseUrlStore } from '$lib/stores'
import { goto } from '$app/navigation'
import { sleep, emptySchema, displayDate } from '$lib/utils'
import { computeSecretUrl } from '$lib/components/apps/editor/appDeploy.svelte'
import {
buildProjectBundle,
@@ -63,7 +63,7 @@
import type { Kind } from '$lib/utils_deployable'
type Phase = 'predeploy' | 'draft' | 'under_review' | 'live'
type RecStatus = 'none' | 'recording' | 'recorded'
type RecStatus = 'none' | 'recorded'
interface DeployItem {
key: string
path: string
@@ -98,40 +98,58 @@
summary?: string
config: Record<string, unknown>
}
const TRIGGER_KIND_BADGE: Record<TriggerKindLabel, string> = {
http: 'HTTP',
websocket: 'WebSocket',
schedule: 'Schedule',
kafka: 'Kafka',
nats: 'NATS',
sqs: 'SQS',
mqtt: 'MQTT',
gcp: 'GCP Pub/Sub',
azure: 'Azure',
postgres: 'Postgres',
email: 'Email'
}
const TRIGGER_KIND_NOTE: Partial<Record<TriggerKindLabel, string>> = {
http: 'Webhook URL regenerates on import — re-register with the external service.',
websocket: 'Reconnect WebSocket auth after import if external service requires it.',
email: 'Email address regenerates on import.',
kafka: 'Verify Kafka broker access from the importing instance.',
nats: 'Verify NATS connection from the importing instance.',
gcp: 'Re-link GCP Pub/Sub subscription after import.',
azure: 'Re-link Azure Event Grid subscription after import.'
}
// Per-kind config field holding the resource path a trigger depends on.
const TRIGGER_RESOURCE_FIELD: Partial<Record<TriggerKindLabel, string>> = {
kafka: 'kafka_resource_path',
nats: 'nats_resource_path',
sqs: 'aws_resource_path',
mqtt: 'mqtt_resource_path',
gcp: 'gcp_resource_path',
azure: 'azure_resource_path',
postgres: 'postgres_resource_path'
// One row per trigger kind: badge label, workspace list route, optional
// post-import note, and the config field holding its resource path.
const TRIGGER_KINDS: Record<
TriggerKindLabel,
{ badge: string; route: string; note?: string; resourceField?: string }
> = {
http: {
badge: 'HTTP',
route: 'routes',
note: 'Webhook URL regenerates on import — re-register with the external service.'
},
websocket: {
badge: 'WebSocket',
route: 'websocket_triggers',
note: 'Reconnect WebSocket auth after import if external service requires it.'
},
schedule: { badge: 'Schedule', route: 'schedules' },
kafka: {
badge: 'Kafka',
route: 'kafka_triggers',
note: 'Verify Kafka broker access from the importing instance.',
resourceField: 'kafka_resource_path'
},
nats: {
badge: 'NATS',
route: 'nats_triggers',
note: 'Verify NATS connection from the importing instance.',
resourceField: 'nats_resource_path'
},
sqs: { badge: 'SQS', route: 'sqs_triggers', resourceField: 'aws_resource_path' },
mqtt: { badge: 'MQTT', route: 'mqtt_triggers', resourceField: 'mqtt_resource_path' },
gcp: {
badge: 'GCP Pub/Sub',
route: 'gcp_triggers',
note: 'Re-link GCP Pub/Sub subscription after import.',
resourceField: 'gcp_resource_path'
},
azure: {
badge: 'Azure',
route: 'azure_triggers',
note: 'Re-link Azure Event Grid subscription after import.',
resourceField: 'azure_resource_path'
},
postgres: {
badge: 'Postgres',
route: 'postgres_triggers',
resourceField: 'postgres_resource_path'
},
email: { badge: 'Email', route: 'email_triggers', note: 'Email address regenerates on import.' }
}
function triggerResourcePath(t: WorkspaceTrigger): string | undefined {
const field = TRIGGER_RESOURCE_FIELD[t.kind]
const field = TRIGGER_KINDS[t.kind]?.resourceField
const v = field ? (t.config as any)?.[field] : undefined
return typeof v === 'string' && v !== '' ? v : undefined
}
@@ -147,10 +165,6 @@
let workspaceLoadSeq = 0
let schedulePreviews = $state<Record<string, string[]>>({})
const schedulePreviewsInFlight = new Set<string>()
const dateFmt = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})
// Project is always scoped to folderProp; selectedFolder is its `f/`-prefixed path.
let selectedFolder = $derived(`f/${folderProp}`)
@@ -158,7 +172,7 @@
return `?folder=${encodeURIComponent(folder)}`
}
let filteredWorkspaceItems = $derived(
selectedFolder ? workspaceItems.filter((i) => i.path.startsWith(selectedFolder + '/')) : []
workspaceItems.filter((i) => i.path.startsWith(selectedFolder + '/'))
)
let items = $derived(phase === 'predeploy' ? filteredWorkspaceItems : draftItems)
let manualDeselected = $state<Set<string>>(new Set())
@@ -167,11 +181,10 @@
phase
manualDeselected = new Set()
})
let selectedItemKeys = $derived(
phase === 'predeploy'
? filteredWorkspaceItems.filter((i) => !manualDeselected.has(i.key)).map((i) => i.key)
: []
let selectedItems = $derived(
phase === 'predeploy' ? filteredWorkspaceItems.filter((i) => !manualDeselected.has(i.key)) : []
)
let selectedItemKeys = $derived(selectedItems.map((i) => i.key))
let allSelected = $derived(
phase === 'predeploy' && selectedItemKeys.length === filteredWorkspaceItems.length
)
@@ -187,9 +200,6 @@
function deselectAll() {
manualDeselected = new Set(filteredWorkspaceItems.map((i) => i.key))
}
let selectedItems = $derived(
filteredWorkspaceItems.filter((i) => selectedItemKeys.includes(i.key))
)
let loading = $state(false)
let workspaceRateLimit = $state<number | undefined>(undefined)
let recordableItems = $derived(items.filter((i) => canRecord(i.kind)))
@@ -197,8 +207,6 @@
recordableItems.length > 0 && recordableItems.every((i) => i.rec === 'recorded')
)
const EMPTY_SCHEMA = { type: 'object', properties: {}, required: [] }
let deploymentStatus = $state<
Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
>({})
@@ -208,7 +216,7 @@
let recordTarget = $state<DeployItem | undefined>()
let recordArgs = $state<Record<string, any>>({})
let recordValid = $state(true)
let recordSchema = $state<Record<string, any>>(EMPTY_SCHEMA)
let recordSchema = $state<Record<string, any>>(emptySchema())
let recordSchemaLoading = $state(false)
type RunState = 'idle' | 'running' | 'success' | 'failed'
let runState = $state<RunState>('idle')
@@ -225,9 +233,6 @@
let resourceDrawer = $state<Drawer | undefined>()
let triggerDrawer = $state<Drawer | undefined>()
let bundleDrawer = $state<Drawer | undefined>()
let bundleName = $state('')
let bundleSummary = $state('')
let bundleReadme = $state('')
let hubName = $state('')
let hubSummary = $state('')
let hubReadme = $state('')
@@ -237,8 +242,6 @@
let hubSlug = $derived(effectiveSlug || sanitizeSlug(hubName))
let hubUrl = $derived(`${$hubBaseUrlStore.replace(/\/+$/, '')}/projects/${hubSlug}`)
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms))
function patchItem(key: string, patch: Partial<DeployItem>) {
workspaceItems = workspaceItems.map((i) => (i.key === key ? { ...i, ...patch } : i))
draftItems = draftItems.map((i) => (i.key === key ? { ...i, ...patch } : i))
@@ -445,9 +448,8 @@
hubName = ''
hubSummary = ''
hubReadme = ''
bundleName = ''
bundleSummary = ''
bundleReadme = ''
previewItemCache.clear()
previewTypeCache.clear()
loadWorkspace($workspaceStore, seq)
loadTriggers($workspaceStore, seq)
// Resume the folder's project if one exists.
@@ -570,46 +572,25 @@
})
function openBundle() {
const folderName = selectedFolder?.split('/').pop() ?? ''
bundleName = bundleName || hubName || folderName
bundleSummary = bundleSummary || hubSummary
bundleReadme = bundleReadme || hubReadme
hubName = hubName || folderProp
bundleDrawer?.openDrawer()
}
const TRIGGER_KIND_ROUTE: Record<TriggerKindLabel, string> = {
http: 'routes',
websocket: 'websocket_triggers',
schedule: 'schedules',
kafka: 'kafka_triggers',
nats: 'nats_triggers',
sqs: 'sqs_triggers',
mqtt: 'mqtt_triggers',
gcp: 'gcp_triggers',
azure: 'azure_triggers',
postgres: 'postgres_triggers',
email: 'email_triggers'
}
function openTriggerUrl(kind: TriggerKindLabel): string | undefined {
const ws = $workspaceStore
if (!ws) return undefined
return `${base}/${TRIGGER_KIND_ROUTE[kind]}?workspace=${ws}`
return `${base}/${TRIGGER_KINDS[kind].route}?workspace=${ws}`
}
const ITEM_KIND_ROUTE: Record<ItemKind, string> = {
script: 'scripts/get',
flow: 'flows/get',
app: 'apps/get',
raw_app: 'apps_raw/get'
}
function openItemUrl(kind: ItemKind, path: string): string | undefined {
const ws = $workspaceStore
if (!ws || !path) return undefined
const segment =
kind === 'script'
? 'scripts/get'
: kind === 'flow'
? 'flows/get'
: kind === 'raw_app'
? 'apps_raw/get'
: kind === 'app'
? 'apps/get'
: undefined
if (!segment) return undefined
return `${base}/${segment}/${path}?workspace=${ws}`
return `${base}/${ITEM_KIND_ROUTE[kind]}/${path}?workspace=${ws}`
}
function sanitizeSlug(s: string): string {
@@ -628,9 +609,9 @@
}
async function confirmBundle() {
hubName = bundleName.trim()
hubSummary = bundleSummary.trim()
hubReadme = bundleReadme.trim()
hubName = hubName.trim()
hubSummary = hubSummary.trim()
hubReadme = hubReadme.trim()
const workspace = $workspaceStore
if (!workspace) return
try {
@@ -639,7 +620,7 @@
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
slug: effectiveSlug || sanitizeSlug(hubName),
slug: hubSlug,
name: hubName,
summary: hubSummary || hubName,
readme: hubReadme || undefined
@@ -866,7 +847,7 @@
continue
}
const config = stripTriggerConfig(t.config)
const field = TRIGGER_RESOURCE_FIELD[t.kind]
const field = TRIGGER_KINDS[t.kind]?.resourceField
if (field && typeof config[field] === 'string') {
config[field] = resourcePathMap.get(config[field] as string) ?? config[field]
}
@@ -994,6 +975,33 @@
return [...byType.values()].sort((a, b) => a.resource_type.localeCompare(b.resource_type))
})
// Preview-only cache: toggling checkboxes re-runs the closure walk, but item
// contents don't change mid-session. deployAll bypasses this and fetches fresh.
const previewItemCache = new Map<string, Promise<FetchedItem | undefined>>()
const previewTypeCache = new Map<string, Promise<string | undefined>>()
function cachedBundleDeps(workspace: string): BundleDeps {
const deps = buildBundleDeps(workspace)
return {
fetchItem: (ref) => {
const key = `${ref.kind}:${ref.path}`
let p = previewItemCache.get(key)
if (!p) {
p = deps.fetchItem(ref)
previewItemCache.set(key, p)
}
return p
},
resolveResourceType: (path) => {
let p = previewTypeCache.get(path)
if (!p) {
p = deps.resolveResourceType(path)
previewTypeCache.set(path, p)
}
return p
}
}
}
$effect(() => {
const workspace = $workspaceStore
selectedItemKeys
@@ -1003,22 +1011,26 @@
}
let cancelled = false
detectingResources = true
const slug = effectiveSlug || sanitizeSlug(hubName)
const slug = hubSlug
const seed: ItemRef[] = selectedItems
.filter((i) => i.kind !== 'resource')
.map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path }))
const triggerResources = relevantTriggers
.map(triggerResourcePath)
.filter((p): p is string => !!p)
buildProjectBundle(seed, slug, buildBundleDeps(workspace), triggerResources)
.then((b) => {
if (!cancelled) bundlePreview = b
})
.finally(() => {
if (!cancelled) detectingResources = false
})
// Debounce so rapid checkbox toggles coalesce into one walk.
const timer = setTimeout(() => {
buildProjectBundle(seed, slug, cachedBundleDeps(workspace), triggerResources)
.then((b) => {
if (!cancelled) bundlePreview = b
})
.finally(() => {
if (!cancelled) detectingResources = false
})
}, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
})
@@ -1057,7 +1069,7 @@
async function deployAll() {
const workspace = $workspaceStore
if (!workspace) return
const slug = effectiveSlug || sanitizeSlug(hubName)
const slug = hubSlug
// Snapshot the selection up-front so a folder switch mid-deploy can't
// rewrite draftItems on success.
const itemsSnapshot = selectedItems.slice()
@@ -1139,19 +1151,22 @@
}
// A re-bundle clears the Hub-side embed (idempotent replace), so re-push it
// for any raw app that is already public — keeps the live iframe in sync
// without forcing an unpublish/share round-trip.
for (const it of bundle.items) {
if (it.kind !== 'raw_app') continue
const hubId = hubItemIds[`${it.kind}:${it.path}`]
const src = itemsSnapshot.find((i) => i.kind === 'raw_app' && i.path === it.path)
if (hubId && src?.published && src?.publicUrl) {
try {
await pushRawAppEmbed(workspace, hubId, src.publicUrl)
} catch (e: any) {
sendUserToast(`Failed to sync iframe for ${it.path}: ${e?.message ?? e}`, true)
}
}
}
// without forcing an unpublish/share round-trip. Updates by hub id, safe in parallel.
await Promise.all(
bundle.items
.filter((it) => it.kind === 'raw_app')
.map(async (it) => {
const hubId = hubItemIds[`${it.kind}:${it.path}`]
const src = itemsSnapshot.find((i) => i.kind === 'raw_app' && i.path === it.path)
if (hubId && src?.published && src?.publicUrl) {
try {
await pushRawAppEmbed(workspace, hubId, src.publicUrl)
} catch (e: any) {
sendUserToast(`Failed to sync iframe for ${it.path}: ${e?.message ?? e}`, true)
}
}
})
)
try {
await pushTriggers(workspace, slug, resourcePathMap, triggersSnapshot)
} catch (e: any) {
@@ -1159,7 +1174,7 @@
failures++
}
await delay(150)
await sleep(150)
deploymentStatus = {}
draftItems = itemsSnapshot.map((i) => ({ ...i, rec: 'none' }))
recordings = {}
@@ -1177,7 +1192,7 @@
let submitting = $state(false)
async function submitForReview() {
const workspace = $workspaceStore
const slug = effectiveSlug || sanitizeSlug(hubName)
const slug = hubSlug
if (!workspace || !slug) return
submitting = true
try {
@@ -1242,7 +1257,7 @@
recordTarget = it
recordArgs = {}
recordValid = true
recordSchema = EMPTY_SCHEMA
recordSchema = emptySchema()
recordSchemaLoading = true
runState = 'idle'
runJobId = undefined
@@ -1258,11 +1273,11 @@
if (it.kind === 'script') {
const s = await ScriptService.getScriptByPath({ workspace, path: it.path })
if (seq !== recordRunSeq) return
recordSchema = (s.schema as Record<string, any>) ?? EMPTY_SCHEMA
recordSchema = (s.schema as Record<string, any>) ?? emptySchema()
} else if (it.kind === 'flow') {
const f = await FlowService.getFlowByPath({ workspace, path: it.path })
if (seq !== recordRunSeq) return
recordSchema = (f.schema as Record<string, any>) ?? EMPTY_SCHEMA
recordSchema = (f.schema as Record<string, any>) ?? emptySchema()
}
} catch (e: any) {
if (seq !== recordRunSeq) return
@@ -1308,8 +1323,10 @@
}
}
async function pollJobUntilComplete(workspace: string, jobId: string, seq: number) {
for (let i = 0; i < 300; i++) {
await delay(1000)
// First check immediately (fast scripts complete in ms), then back off to 2s.
const deadline = Date.now() + 5 * 60_000
let interval = 250
while (Date.now() < deadline) {
if (seq !== recordRunSeq) return
try {
const r = await JobService.getCompletedJobResultMaybe({
@@ -1333,6 +1350,8 @@
runError = `Polling failed: ${e?.message ?? e}`
return
}
await sleep(interval)
interval = Math.min(interval * 2, 2000)
}
if (seq !== recordRunSeq) return
runState = 'failed'
@@ -1368,17 +1387,20 @@
initial_job: stamped,
events: [{ t: 0, data: { completed: true, job: stamped } }]
}
const modules = j.flow_status?.modules ?? []
for (const m of modules) {
if (m.job && typeof m.job === 'string') {
const modules = (j.flow_status?.modules ?? []).filter(
(m: any) => m.job && typeof m.job === 'string'
)
// Sub-jobs at the same level are independent reads.
await Promise.all(
modules.map(async (m: any) => {
try {
const sub = (await JobService.getCompletedJob({ workspace, id: m.job })) as any
await collect(sub)
} catch {
/* sub-job missing — skip */
}
}
}
})
)
}
await collect(root)
return {
@@ -1421,7 +1443,7 @@
const path = it.kind === 'script' ? 'scripts' : 'flows'
await postHub(workspace, `/hub/${path}/${hubId}/recording`, {
recording,
project_slug: effectiveSlug || sanitizeSlug(hubName)
project_slug: hubSlug
})
recordings = { ...recordings, [it.key]: runJobId }
patchItem(it.key, { rec: 'recorded' })
@@ -1441,7 +1463,7 @@
async function pushRawAppEmbed(workspace: string, hubId: number, url: string | null) {
await postHub(workspace, `/hub/raw_apps/${hubId}/embed`, {
external_embed_url: url,
project_slug: effectiveSlug || sanitizeSlug(hubName)
project_slug: hubSlug
})
}
@@ -1583,7 +1605,7 @@
<Button
variant="accent"
loading={deploying}
disabled={!selectedFolder || selectedItems.length === 0}
disabled={selectedItems.length === 0}
startIcon={{ icon: Cloud }}
onclick={openBundle}
>
@@ -1623,17 +1645,6 @@
{selectedItems.length} of {filteredWorkspaceItems.length} items selected.
</span>
</div>
{/if}
{#if phase === 'draft'}
<div class="flex flex-col gap-1 pb-3">
<span class="text-xs text-secondary">
A recording captures one real run of a script or flow — inputs, logs, step outputs and
result — replayable on the Hub so visitors see it work before forking. Public apps can
also be shared as live iframes. Optional, but recommended.
</span>
</div>
{/if}
{#if phase === 'predeploy'}
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="font-semibold text-primary shrink-0">
Resource dependencies
@@ -1673,6 +1684,15 @@
{/if}
</div>
{/if}
{#if phase === 'draft'}
<div class="flex flex-col gap-1 pb-3">
<span class="text-xs text-secondary">
A recording captures one real run of a script or flow — inputs, logs, step outputs and
result — replayable on the Hub so visitors see it work before forking. Public apps can
also be shared as live iframes. Optional, but recommended.
</span>
</div>
{/if}
{#if phase === 'predeploy'}
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="font-semibold text-primary shrink-0">
@@ -1690,7 +1710,7 @@
<span
class="inline-flex items-center gap-1 rounded border bg-surface px-1.5 py-0.5 font-mono text-[11px] text-secondary"
>
{TRIGGER_KIND_BADGE[kind]}
{TRIGGER_KINDS[kind].badge}
<span class="text-hint">×{triggers.length}</span>
</span>
{/each}
@@ -1779,7 +1799,6 @@
<Button
size="xs"
variant="subtle"
loading={it.rec === 'recording'}
startIcon={{ icon: Play }}
onclick={() => openRecord(it)}
>
@@ -1810,10 +1829,10 @@
>
Copy iframe
</Button>
{#if canPublishApp(it.kind) && phase !== 'under_review'}
{#if phase !== 'under_review'}
<Button size="xs" variant="subtle" onclick={() => unpublishApp(it)}>Unpublish</Button>
{/if}
{:else if canPublishApp(it.kind) && phase !== 'under_review'}
{:else if phase !== 'under_review'}
<Button
size="xs"
variant="subtle"
@@ -1993,13 +2012,9 @@
</span>
{/if}
<a
href="?tab=default_app"
href="{base}/workspace_settings?tab=default_app"
class="text-[11px] text-blue-600 underline"
onclick={(e) => {
e.preventDefault()
publishDrawer?.closeDrawer()
goto('?tab=default_app')
}}
onclick={() => publishDrawer?.closeDrawer()}
>
Edit in Workspace settings → Apps
</a>
@@ -2054,7 +2069,7 @@
<span
class="ml-auto inline-flex shrink-0 items-center gap-1 rounded bg-surface px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-secondary"
>
{TRIGGER_KIND_BADGE[u.triggerKind]} trigger
{TRIGGER_KINDS[u.triggerKind].badge} trigger
</span>
</div>
{:else}
@@ -2136,12 +2151,12 @@
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
<div class="flex items-center gap-2 border-b pb-2">
<span class="rounded border bg-surface px-1.5 py-0.5 font-mono text-xs text-primary">
{TRIGGER_KIND_BADGE[kind]}
{TRIGGER_KINDS[kind].badge}
</span>
<span class="text-[11px] text-hint">
{triggers.length} trigger{triggers.length > 1 ? 's' : ''}
</span>
{#if TRIGGER_KIND_NOTE[kind]}
{#if TRIGGER_KINDS[kind].note}
<span class="ml-auto">
<Popover notClickable placement="top">
<Info size={12} class="text-blue-600 dark:text-blue-400" />
@@ -2149,7 +2164,7 @@
<div
class="flex w-72 max-w-[90vw] flex-col gap-1 text-left text-[11px] normal-case"
>
<span>{TRIGGER_KIND_NOTE[kind]}</span>
<span>{TRIGGER_KINDS[kind].note}</span>
</div>
{/snippet}
</Popover>
@@ -2186,7 +2201,7 @@
href={triggerUrl}
target="_blank"
rel="noopener"
title="Open {TRIGGER_KIND_BADGE[t.kind]} trigger list in new tab"
title="Open {TRIGGER_KINDS[t.kind].badge} trigger list in new tab"
class="shrink-0 text-hint hover:text-primary"
>
<ExternalLink size={12} />
@@ -2205,7 +2220,7 @@
{#if preview && preview.length > 0}
<div class="flex flex-col gap-0.5">
{#each preview as date (date)}
<span>{dateFmt.format(new Date(date))}</span>
<span>{displayDate(date)}</span>
{/each}
</div>
{:else if preview && preview.length === 0}
@@ -2236,37 +2251,37 @@
</p>
<label class="flex flex-col gap-1 text-xs">
<span class="font-semibold text-primary">Name</span>
<TextInput bind:value={bundleName} inputProps={{ placeholder: 'e.g. Acme CRM toolkit' }} />
<TextInput bind:value={hubName} inputProps={{ placeholder: 'e.g. Acme CRM toolkit' }} />
</label>
<div class="flex flex-col gap-1 text-xs">
<span class="font-semibold text-primary">Project slug</span>
<span class="rounded border bg-surface-secondary px-2 py-1.5 font-mono text-secondary">
{effectiveSlug || sanitizeSlug(bundleName) || '—'}
{effectiveSlug || sanitizeSlug(hubName) || '—'}
</span>
<span class="text-[11px] text-hint">
{#if effectiveSlug}
Locked — items live under <span class="font-mono">f/{effectiveSlug}/</span>.
{:else if bundleName.trim() && !isValidSlug(sanitizeSlug(bundleName))}
{:else if hubName.trim() && !isValidSlug(sanitizeSlug(hubName))}
<span class="text-red-600 dark:text-red-400">
The name yields an invalid slug. Use at least 3 letters/digits.
</span>
{:else}
Auto-generated from the name. Once project forked, items will live under
<span class="font-mono">f/{sanitizeSlug(bundleName) || '<slug>'}/</span>.
<span class="font-mono">f/{sanitizeSlug(hubName) || '<slug>'}/</span>.
{/if}
</span>
</div>
<label class="flex flex-col gap-1 text-xs">
<span class="font-semibold text-primary">Summary</span>
<TextInput
bind:value={bundleSummary}
bind:value={hubSummary}
inputProps={{ placeholder: 'Short one-liner shown on the Hub card' }}
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="font-semibold text-primary">Readme</span>
<textarea
bind:value={bundleReadme}
bind:value={hubReadme}
placeholder={"# What this workspace does\n\n# Who it's for\n\n# How to use it\n"}
rows="10"
class="rounded border px-2 py-1.5 text-xs font-mono bg-surface"
@@ -2280,7 +2295,7 @@
<Button
variant="accent"
loading={deploying}
disabled={!bundleName.trim() || (!effectiveSlug && !isValidSlug(sanitizeSlug(bundleName)))}
disabled={!hubName.trim() || (!effectiveSlug && !isValidSlug(sanitizeSlug(hubName)))}
startIcon={{ icon: Cloud }}
onclick={confirmBundle}
>
@@ -14,6 +14,8 @@
// This module is intentionally free of API/Svelte deps so it can be unit-tested.
// The async closure orchestrator that fetches items lives in the component.
import { getAllModules } from '$lib/components/flows/flowExplorer'
export type RefKind = 'resource' | 'script' | 'flow'
export interface Ref {
@@ -64,13 +66,11 @@ export function extractFlowRefs(value: any): Ref[] {
out.push({ kind, path })
}
}
const visitModules = (modules: any[]) => {
if (!Array.isArray(modules)) return
for (const mod of modules) visitModule(mod)
}
const visitModule = (mod: any) => {
const v = mod?.value
if (!v || typeof v !== 'object') return
// getAllModules flattens the whole tree (loops, branches, aiagent tools,
// failure module) so each module only needs local inspection.
for (const mod of getAllModules(value?.modules ?? [], value?.failure_module)) {
const v: any = (mod as any)?.value
if (!v || typeof v !== 'object') continue
if (v.type === 'script' && typeof v.path === 'string') add('script', v.path)
if (v.type === 'flow' && typeof v.path === 'string') add('flow', v.path)
if (typeof v.content === 'string') {
@@ -86,11 +86,7 @@ export function extractFlowRefs(value: any): Ref[] {
}
}
}
if (Array.isArray(v.modules)) visitModules(v.modules)
if (Array.isArray(v.branches)) for (const b of v.branches) visitModules(b?.modules)
if (Array.isArray(v.default)) visitModules(v.default)
}
visitModules(value?.modules)
return out
}
@@ -139,13 +135,9 @@ export function rewriteContent(content: string, map: Map<string, string>): strin
export function rewriteFlowValue(value: any, map: Map<string, string>): any {
const cloned = JSON.parse(JSON.stringify(value ?? {}))
const visitModules = (modules: any[]) => {
if (!Array.isArray(modules)) return
for (const mod of modules) visitModule(mod)
}
const visitModule = (mod: any) => {
const v = mod?.value
if (!v || typeof v !== 'object') return
for (const mod of getAllModules(cloned?.modules ?? [], cloned?.failure_module)) {
const v: any = (mod as any)?.value
if (!v || typeof v !== 'object') continue
if (
(v.type === 'script' || v.type === 'flow') &&
typeof v.path === 'string' &&
@@ -164,16 +156,12 @@ export function rewriteFlowValue(value: any, map: Map<string, string>): any {
}
}
}
if (Array.isArray(v.modules)) visitModules(v.modules)
if (Array.isArray(v.branches)) for (const b of v.branches) visitModules(b?.modules)
if (Array.isArray(v.default)) visitModules(v.default)
}
visitModules(cloned?.modules)
return cloned
}
// Round-trips through JSON since app values are opaque.
export function rewriteAppValue(value: any, map: Map<string, string>): any {
function rewriteAppValue(value: any, map: Map<string, string>): any {
if (value == null) return value
const json = JSON.stringify(value)
return JSON.parse(rewriteContent(json, map))
@@ -246,8 +234,7 @@ export async function buildProjectBundle(
extraResourcePaths: string[] = []
): Promise<ProjectBundle> {
const fetched = new Map<string, FetchedItem>()
const queued = new Set<string>(seed.map((s) => s.path))
const queue: ItemRef[] = [...seed]
const queued = new Set<string>()
const resourcePaths = new Set<string>()
const unresolved: string[] = []
@@ -256,26 +243,38 @@ export async function buildProjectBundle(
if (classifyPath(p, slug) !== 'hub') resourcePaths.add(p)
}
while (queue.length > 0) {
const ref = queue.shift()!
if (fetched.has(ref.path)) continue
const item = await deps.fetchItem(ref)
if (!item) {
unresolved.push(ref.path)
continue
// Refs at the same BFS depth are independent: fetch each level concurrently.
let level: ItemRef[] = []
for (const s of seed) {
if (!queued.has(s.path)) {
queued.add(s.path)
level.push(s)
}
fetched.set(ref.path, item)
for (const r of refsForFetched(item)) {
if (classifyPath(r.path, slug) === 'hub') continue
if (r.kind === 'resource') {
resourcePaths.add(r.path)
} else if (r.kind === 'script' || r.kind === 'flow') {
if (!fetched.has(r.path) && !queued.has(r.path)) {
queued.add(r.path)
queue.push({ kind: r.kind, path: r.path })
}
while (level.length > 0) {
const results = await Promise.all(
level.map(async (ref) => ({ ref, item: await deps.fetchItem(ref) }))
)
const next: ItemRef[] = []
for (const { ref, item } of results) {
if (!item) {
unresolved.push(ref.path)
continue
}
fetched.set(ref.path, item)
for (const r of refsForFetched(item)) {
if (classifyPath(r.path, slug) === 'hub') continue
if (r.kind === 'resource') {
resourcePaths.add(r.path)
} else if (r.kind === 'script' || r.kind === 'flow') {
if (!queued.has(r.path)) {
queued.add(r.path)
next.push({ kind: r.kind, path: r.path })
}
}
}
}
level = next
}
const itemPaths = [...fetched.keys()]
@@ -295,8 +294,10 @@ export async function buildProjectBundle(
})
const resourceStubs: ResourceStub[] = []
for (const path of resourcePaths) {
const type = await deps.resolveResourceType(path)
const resolved = await Promise.all(
[...resourcePaths].map(async (path) => ({ path, type: await deps.resolveResourceType(path) }))
)
for (const { path, type } of resolved) {
if (!type) {
unresolved.push(path)
continue