Init record logic

This commit is contained in:
tristantr
2026-05-22 15:46:28 +02:00
parent 3843376913
commit bc06337cce
@@ -1,18 +1,23 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
import { workspaceStore } from '$lib/stores'
import { AppService, FlowService, RawAppService, ResourceService, ScriptService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import {
AppWindow,
Check,
ChevronDown,
ChevronRight,
CircleSlash,
Database,
ExternalLink,
FileCode2,
Layout,
Loader2,
Play,
RotateCcw,
Workflow
} from 'lucide-svelte'
import { onMount } from 'svelte'
@@ -26,13 +31,28 @@
resourceType?: string
}
// Which buckets need a per-item artifact before deploy.
// script/flow → execution recording, app/raw_app → built bundle.
type Artifact = 'recording' | 'bundle'
function artifactOf(kind: Bucket): Artifact | undefined {
if (kind === 'script' || kind === 'flow') return 'recording'
if (kind === 'app' || kind === 'raw_app') return 'bundle'
return undefined
}
type ItemStatus = 'idle' | 'running' | 'done' | 'error'
interface ItemState {
status: ItemStatus
durationMs?: number
}
const BUCKET_ORDER: Bucket[] = ['app', 'raw_app', 'flow', 'script', 'resource']
const BUCKET_LABEL: Record<Bucket, string> = {
app: 'Apps',
raw_app: 'Raw apps',
flow: 'Flows',
script: 'Scripts',
resource: 'Resources'
resource: 'Resource types'
}
const BUCKET_ICON = {
app: AppWindow,
@@ -44,14 +64,22 @@
let items = $state<Item[]>([])
let loadingItems = $state(false)
let deploying = $state(false)
let collapsed = $state<Partial<Record<Bucket, boolean>>>({})
let states = $state<Record<string, ItemState>>({})
let deploying = $state(false)
let grouped = $derived(
BUCKET_ORDER.map((b) => ({ bucket: b, list: items.filter((i) => i.kind === b) })).filter(
(g) => g.list.length > 0
)
)
// Recording input drawer: clicking Record on a script/flow opens a form built
// from the item's JSON schema so the user provides the args used for the run.
let recordDrawer = $state<Drawer | undefined>()
let recordTarget = $state<Item | undefined>()
let recordSchema = $state<Record<string, any> | undefined>()
let recordArgs = $state<Record<string, any>>({})
let recordValid = $state(true)
let loadingSchema = $state(false)
function idOf(it: Item): string {
return it.kind + ':' + it.path
}
async function loadItems() {
if (!$workspaceStore) return
@@ -70,36 +98,119 @@
...rawApps.map((a) => ({ path: a.path, kind: 'raw_app' as const, summary: a.summary })),
...flows.map((f) => ({ path: f.path, kind: 'flow' as const, summary: f.summary })),
...scripts.map((s) => ({ path: s.path, kind: 'script' as const, summary: s.summary })),
...resources
// Exclude the auto-created app theme resources present in every workspace.
.filter((r) => r.resource_type !== 'app_theme')
.map((r) => ({
path: r.path,
kind: 'resource' as const,
summary: r.description,
resourceType: r.resource_type
}))
// Only known/shared resource types: dedupe the resource_type of each resource,
// excluding the auto-created app_theme and workspace-local custom types (`c_*`).
...[
...new Set(
resources
.map((r) => r.resource_type)
.filter((rt): rt is string => !!rt && rt !== 'app_theme' && !rt.startsWith('c_'))
)
].map((rt) => ({ path: rt, kind: 'resource' as const, resourceType: rt }))
]
} finally {
loadingItems = false
}
}
let grouped = $derived(
BUCKET_ORDER.map((b) => ({ bucket: b, list: items.filter((i) => i.kind === b) })).filter(
(g) => g.list.length > 0
)
)
// Items that require an artifact (recording or bundle) before deploy.
let actionable = $derived(items.filter((i) => artifactOf(i.kind) !== undefined))
let doneCount = $derived(actionable.filter((i) => states[idOf(i)]?.status === 'done').length)
let allDone = $derived(actionable.length > 0 && doneCount === actionable.length)
function statusOf(it: Item): ItemState {
return states[idOf(it)] ?? { status: 'idle' }
}
function toggle(b: Bucket) {
collapsed = { ...collapsed, [b]: !collapsed[b] }
}
// MOCK: simulate running the script/flow with `args` (recording) or building the
// app bundle. Real impl will drive the recording stores + JobLoader.
async function runItem(it: Item, _args: Record<string, any> = {}): Promise<void> {
const id = idOf(it)
states = { ...states, [id]: { status: 'running' } }
const ms = 700 + Math.round(Math.random() * 1500)
await new Promise((r) => setTimeout(r, ms))
// MOCK: ~10% failure to show the error state.
const failed = Math.random() < 0.1
states = {
...states,
[id]: failed ? { status: 'error' } : { status: 'done', durationMs: ms }
}
}
// Fetch the JSON schema of a script/flow so the input form can be rendered.
async function fetchSchema(it: Item): Promise<Record<string, any> | undefined> {
if (!$workspaceStore) return undefined
const workspace = $workspaceStore
if (it.kind === 'script') {
const s = await ScriptService.getScriptByPath({ workspace, path: it.path })
return s.schema as Record<string, any> | undefined
}
if (it.kind === 'flow') {
const f = await FlowService.getFlowByPath({ workspace, path: it.path })
return f.schema as Record<string, any> | undefined
}
return undefined
}
// Entry point for the per-item action button.
async function startItem(it: Item): Promise<void> {
const artifact = artifactOf(it.kind)
if (artifact === 'bundle') {
// Apps need no inputs — build directly.
await runItem(it)
return
}
// Recording: collect inputs first via the drawer.
recordTarget = it
recordArgs = {}
recordValid = true
recordSchema = undefined
recordDrawer?.openDrawer()
loadingSchema = true
try {
recordSchema = await fetchSchema(it)
} finally {
loadingSchema = false
}
}
async function confirmRecord(): Promise<void> {
const it = recordTarget
if (!it) return
recordDrawer?.closeDrawer()
await runItem(it, recordArgs)
}
function cancelRecord(): void {
recordDrawer?.closeDrawer()
}
async function deployToHub() {
if (items.length === 0) return
deploying = true
try {
// TODO: build the bundle and POST it to the hub `/workspaces/add` endpoint.
sendUserToast(`Deploying ${items.length} item(s) to the Hub`)
// TODO: bundle (tarball export + recordings + app bundles + resource types)
// and POST to the Hub.
await new Promise((r) => setTimeout(r, 600))
sendUserToast(`Deployed ${items.length} item(s) to the Hub`)
} finally {
deploying = false
}
}
function actionVerb(a: Artifact): string {
return a === 'recording' ? 'Record' : 'Build'
}
onMount(() => {
loadItems()
})
@@ -114,10 +225,15 @@
<p class="text-sm text-secondary">No deployable items found in this workspace.</p>
{:else}
<div class="border rounded-md bg-surface-tertiary overflow-hidden">
<div class="px-3 py-2.5 border-b text-xs font-semibold uppercase tracking-wide text-tertiary">
Files
<div
class="px-3 py-2.5 border-b flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-tertiary"
>
<span>Files</span>
<span class="ml-auto normal-case font-medium text-hint">
{doneCount}/{actionable.length} ready
</span>
</div>
<div class="py-2 overflow-y-auto max-h-[calc(100dvh-20rem)]">
<div class="py-2 overflow-y-auto max-h-[calc(100dvh-22rem)]">
{#each grouped as { bucket, list } (bucket)}
{@const Icon = BUCKET_ICON[bucket]}
{@const open = !collapsed[bucket]}
@@ -138,8 +254,10 @@
{#if open}
<ul>
{#each list as it (it.kind + ':' + it.path)}
{@const artifact = artifactOf(it.kind)}
{@const st = statusOf(it)}
<li
class="flex items-center gap-2 pl-8 pr-3 py-1 text-xs text-primary truncate"
class="flex items-center gap-2 pl-8 pr-3 py-1 text-xs text-primary"
title={it.path}
>
{#if it.kind === 'resource'}
@@ -156,8 +274,51 @@
{:else}
<span class="truncate">{it.path}</span>
{/if}
<span class="ml-auto flex items-center gap-1 text-[10px] text-hint">
<CircleSlash size={11} /> Referenced
</span>
{:else}
<span class="truncate">{it.summary?.trim() || it.path}</span>
{#if artifact}
<div class="ml-auto flex items-center gap-2 shrink-0">
{#if st.status === 'done'}
<span class="flex items-center gap-1 text-[10px] text-green-600">
<Check size={12} />
{artifact === 'recording' ? 'Recorded' : 'Built'}
{#if st.durationMs}
<span class="text-hint">({(st.durationMs / 1000).toFixed(1)}s)</span>
{/if}
</span>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: RotateCcw }}
onclick={() => startItem(it)}
/>
{:else if st.status === 'error'}
<span class="text-[10px] text-red-500">Failed</span>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: RotateCcw }}
onclick={() => startItem(it)}
>
Retry
</Button>
{:else}
<Button
variant="default"
unifiedSize="sm"
loading={st.status === 'running'}
startIcon={{ icon: Play }}
onclick={() => startItem(it)}
>
{actionVerb(artifact)}
</Button>
{/if}
</div>
{/if}
{/if}
</li>
{/each}
@@ -167,10 +328,16 @@
</div>
</div>
<div class="flex justify-end">
<div class="flex items-center justify-end gap-3">
{#if !allDone}
<span class="text-[11px] text-hint">
Record all scripts/flows and build all apps before deploying.
</span>
{/if}
<Button
variant="accent"
loading={deploying}
disabled={!allDone}
startIcon={{ icon: ExternalLink }}
onclick={deployToHub}
>
@@ -179,3 +346,37 @@
</div>
{/if}
</div>
<Drawer bind:this={recordDrawer} size="600px" on:close={cancelRecord}>
<DrawerContent
title={recordTarget ? `Record — ${recordTarget.path}` : 'Record'}
on:close={cancelRecord}
>
<div class="flex flex-col gap-3">
<p class="text-xs text-secondary">
Provide the inputs to run this {recordTarget?.kind} once. The run is captured as a recording
and shipped with the workspace to the Hub.
</p>
{#if loadingSchema}
<div class="flex items-center gap-2 text-secondary text-sm">
<Loader2 class="animate-spin" size={16} /> Loading inputs…
</div>
{:else if recordSchema}
<SchemaForm bind:args={recordArgs} bind:isValid={recordValid} schema={recordSchema} />
{:else}
<p class="text-sm text-secondary">This item has no inputs.</p>
{/if}
</div>
{#snippet actions()}
<Button variant="default" onclick={cancelRecord}>Cancel</Button>
<Button
variant="accent"
disabled={loadingSchema || !recordValid}
startIcon={{ icon: Play }}
onclick={confirmRecord}
>
Run & record
</Button>
{/snippet}
</DrawerContent>
</Drawer>