From ac5e7a99041a0c35559baf6850871363edd5dc07 Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 27 May 2026 16:01:10 +0200 Subject: [PATCH] feat(deploy-to-hub): wire frontend to backend hub proxy Replaces the mocked deploy flow with real backend calls: - confirmBundle() POSTs /hub/publish_draft with sanitized slug, name, summary and readme. - deployAll() pushes selectedItems one by one via pushItem(), fetching the live content (Script/Flow/AppService + raw_apps get_data) before forwarding to /hub/{scripts,flows,apps,raw_apps}. - saveRecording() builds the replay-shaped payload expected by the Hub (initial_job + events with type: 'CompletedJob') and POSTs to /hub/{scripts,flows}/{hub_id}/recording. - Adds bundleSummary state + TextInput in the drawer. Hub item ids (ask_id / flow_id) returned by the create calls are cached client-side to wire later recording uploads. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workspaceSettings/DeployToHub.svelte | 257 ++++++++++++++++-- 1 file changed, 240 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte index 654b4ae50b..500c0145e4 100644 --- a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte +++ b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte @@ -146,9 +146,11 @@ let bundleDrawer = $state() let bundleName = $state('') + let bundleSummary = $state('') let bundleReadme = $state('') // MOCK: would be persisted with the Hub draft. let hubName = $state('') + let hubSummary = $state('') let hubReadme = $state('') const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -269,36 +271,166 @@ const folderShort = selectedFolders.length === 1 ? selectedFolders[0].split('/').slice(1).join('/') : '' bundleName = hubName || folderShort || ($workspaceStore ?? '') + bundleSummary = hubSummary bundleReadme = hubReadme bundleDrawer?.openDrawer() } + function sanitizeSlug(s: string): string { + return s + .toLowerCase() + .replace(/[_\s]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .slice(0, 50) + } + async function confirmBundle() { hubName = bundleName.trim() + hubSummary = bundleSummary.trim() hubReadme = bundleReadme.trim() + const workspace = $workspaceStore + if (!workspace) return + try { + const res = await fetch(`/api/w/${workspace}/hub/publish_draft`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + slug: sanitizeSlug(hubSlug), + name: hubName, + summary: hubSummary || hubName, + readme: hubReadme || undefined + }) + }) + if (!res.ok) { + const text = await res.text() + sendUserToast(`Hub draft creation failed: ${text}`, true) + return + } + } catch (e: any) { + sendUserToast(`Hub draft creation failed: ${e?.message ?? e}`, true) + return + } bundleDrawer?.closeDrawer() await deployAll() } + + let hubItemIds = $state>({}) + + async function pushItem(workspace: string, slug: string, it: DeployItem): Promise { + if (it.kind === 'script') { + const s = await ScriptService.getScriptByPath({ workspace, path: it.path }) + const body = { + summary: s.summary || it.path, + app: slug, + description: s.description ?? '', + kind: typeof s.kind === 'string' ? s.kind.toLowerCase() : 'script', + content: s.content, + language: s.language, + schema: s.schema ?? undefined, + lockfile: s.lock ?? undefined, + workspace_slug: slug + } + const resp = await postHub(workspace, '/hub/scripts', body) + const id = resp?.id + if (typeof id === 'number') hubItemIds = { ...hubItemIds, [it.key]: id } + } else if (it.kind === 'flow') { + const f = await FlowService.getFlowByPath({ workspace, path: it.path }) + const body = { + flow: { + summary: f.summary || it.path, + description: f.description ?? undefined, + value: f.value, + schema: f.schema ?? undefined + }, + apps: [], + workspace_slug: slug + } + const resp = await postHub(workspace, '/hub/flows', body) + const id = resp?.id + if (typeof id === 'number') hubItemIds = { ...hubItemIds, [it.key]: id } + } else if (it.kind === 'app') { + const a = await AppService.getAppByPath({ workspace, path: it.path }) + const body = { + app: a.value, + apps: [], + summary: a.summary || it.path, + description: undefined, + workspace_slug: slug + } + await postHub(workspace, '/hub/apps', body) + } else if (it.kind === 'raw_app') { + const r = await fetch(`/api/w/${workspace}/raw_apps/get_data/0/${it.path}`, { + credentials: 'include' + }) + if (!r.ok) throw new Error(`fetch raw_app ${it.path}: ${await r.text()}`) + const raw = await r.text() + const body = { + raw, + apps: [], + summary: it.summary || it.path, + description: undefined, + workspace_slug: slug + } + await postHub(workspace, '/hub/raw_apps', body) + } else { + throw new Error(`unsupported kind ${it.kind} in v1`) + } + } + + async function postHub( + workspace: string, + path: string, + body: unknown + ): Promise | undefined> { + const res = await fetch(`/api/w/${workspace}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body) + }) + const text = await res.text() + if (!res.ok) throw new Error(text) + try { + return JSON.parse(text) + } catch { + return undefined + } + } + async function deployAll() { - // MOCK: bundle/version push to the Hub is not implemented backend-side. + const workspace = $workspaceStore + if (!workspace) return + const slug = sanitizeSlug(hubSlug) deploying = true + let failures = 0 try { for (const it of selectedItems) { deploymentStatus = { ...deploymentStatus, [it.key]: { status: 'loading' } } - await delay(120) - deploymentStatus = { ...deploymentStatus, [it.key]: { status: 'deployed' } } + try { + await pushItem(workspace, slug, it) + deploymentStatus = { ...deploymentStatus, [it.key]: { status: 'deployed' } } + } catch (e: any) { + failures++ + deploymentStatus = { + ...deploymentStatus, + [it.key]: { status: 'failed', error: e?.message ?? String(e) } + } + } } await delay(150) deploymentStatus = {} - // Freeze the set of items for this draft cycle. Workspace changes after this point - // will not affect the draft until a new draft cycle is started. draftItems = selectedItems.map((i) => ({ ...i, rec: 'none' })) recordings = {} phase = 'draft' - sendUserToast( - hubVersion === 0 - ? `Draft created on the Hub. Add recordings before submitting for review.` - : `New draft created (will become v${hubVersion + 1} after review).` - ) + if (failures > 0) { + sendUserToast(`Draft pushed with ${failures} failed item(s).`, true) + } else { + sendUserToast( + hubVersion === 0 + ? `Draft created on the Hub. Add recordings before submitting for review.` + : `New draft created (will become v${hubVersion + 1} after review).` + ) + } } finally { deploying = false } @@ -455,14 +587,98 @@ runState = 'failed' runError = 'Timed out after 5 minutes' } - function saveRecording() { + async function buildScriptRecording(workspace: string, it: DeployItem, jobId: string) { + const s = await ScriptService.getScriptByPath({ workspace, path: it.path }) + const job = await JobService.getCompletedJob({ workspace, id: jobId }) + const initial_job = { ...(job as any), type: 'CompletedJob' } + const events = [{ t: 0, data: { completed: true, job: initial_job } }] + const duration = (initial_job.duration_ms as number) ?? 0 + return { + version: 1, + type: 'script' as const, + recorded_at: new Date().toISOString(), + script_path: it.path, + total_duration_ms: duration, + code: s.content, + language: s.language, + args: (job.args ?? {}) as Record, + schema: s.schema, + job: { initial_job, events } + } + } + + async function buildFlowRecording(workspace: string, it: DeployItem, jobId: string) { + const f = await FlowService.getFlowByPath({ workspace, path: it.path }) + const root = (await JobService.getCompletedJob({ workspace, id: jobId })) as any + const jobs: Record = {} + const collect = async (j: any) => { + const stamped = { ...j, type: 'CompletedJob' } + jobs[j.id] = { + 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') { + try { + const sub = (await JobService.getCompletedJob({ workspace, id: m.job })) as any + await collect(sub) + } catch { + /* sub-job missing — skip */ + } + } + } + } + await collect(root) + return { + version: 1, + recorded_at: new Date().toISOString(), + flow_path: it.path, + total_duration_ms: (root.duration_ms as number) ?? 0, + flow: { + path: it.path, + value: f.value, + schema: f.schema ?? { type: 'object', properties: {}, required: [] }, + summary: f.summary ?? '', + archived: false, + edited_at: '', + edited_by: '', + extra_perms: {} + }, + jobs + } + } + + async function saveRecording() { const it = recordTarget - if (!it || !runJobId || runState !== 'success') return - // MOCK STORAGE: would persist {item_path, hub_version, job_id} server-side. - recordings = { ...recordings, [it.key]: runJobId } - patchItem(it.key, { rec: 'recorded' }) - sendUserToast(`Recording saved — job ${runJobId}`) - recordDrawer?.closeDrawer() + const workspace = $workspaceStore + if (!it || !runJobId || runState !== 'success' || !workspace) return + const hubId = hubItemIds[it.key] + if (!hubId) { + sendUserToast(`Push the bundle to the Hub first before saving recordings`, true) + return + } + if (it.kind !== 'script' && it.kind !== 'flow') { + sendUserToast(`Recordings only supported for script/flow`, true) + return + } + try { + const recording = + it.kind === 'script' + ? await buildScriptRecording(workspace, it, runJobId) + : await buildFlowRecording(workspace, it, runJobId) + const path = it.kind === 'script' ? 'scripts' : 'flows' + await postHub(workspace, `/hub/${path}/${hubId}/recording`, { + recording, + workspace_slug: sanitizeSlug(hubSlug) + }) + recordings = { ...recordings, [it.key]: runJobId } + patchItem(it.key, { rec: 'recorded' }) + sendUserToast(`Recording saved — job ${runJobId}`) + recordDrawer?.closeDrawer() + } catch (e: any) { + sendUserToast(`Failed to save recording: ${e?.message ?? e}`, true) + } } function openPublish(it: DeployItem) { @@ -986,6 +1202,13 @@ Name +