diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 23790c5..54d53e0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,20 @@ "Bash(mix assets.build:*)", "Bash(wc:*)", "Bash(npx vue-tsc:*)", - "Bash(tail:*)" + "Bash(tail:*)", + "Bash(mix deps.tree)", + "Bash(xargs basename:*)", + "Bash(find /Users/galdirie/code/fizz/deps/runic -type f -name *.ex)", + "WebFetch(domain:hexdocs.pm)", + "WebFetch(domain:github.com)", + "WebFetch(domain:litestream.io)", + "WebFetch(domain:hex.pm)", + "Bash(find /home/galad/code/fizz/deps/live_vue -name *.ex -o -name *.js -o -name *.ts)", + "WebFetch(domain:raw.githubusercontent.com)", + "mcp__plugin_context7_context7__resolve-library-id" ] + }, + "enabledPlugins": { + "superpowers@claude-plugins-official": false } } diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index d048660..41138f4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ fizz-*.tar # Ignore assets that are produced by build tools. /priv/static/* +/priv/workflow_data/ # In case you use Node.js/npm, you want to ignore these. npm-debug.log @@ -38,3 +39,8 @@ node_modules/ .env .env.* + + +.DS_Store + +*.deb \ No newline at end of file diff --git a/README.md b/README.md index d3e1b7f..0a432ad 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,49 @@ # Fizz +example_workflow + +## Local Stack + +The durable workflow stack runs locally against the real components: + +- Postgres in Docker +- MinIO as an S3-compatible replica target for Litestream +- node-local SQLite files under `priv/workflow_data` +- the real `litestream` binary on your host `PATH` + +Start the local resources: + +```bash +docker compose -f docker-compose.resources.yml up -d +``` + +Useful local endpoints: + +- App: [http://localhost:4000](http://localhost:4000) +- Adminer: [http://localhost:8080](http://localhost:8080) +- MinIO API: [http://localhost:9000](http://localhost:9000) +- MinIO Console: [http://localhost:9001](http://localhost:9001) + +Development defaults are already wired for the workflow stack: + +- bucket: `fizz-workflows-dev` +- endpoint: `http://127.0.0.1:9000` +- access key: `minioadmin` +- secret key: `minioadmin` +- workflow data dir: `priv/workflow_data` + +Override them with environment variables when needed: + +- `WORKFLOW_DATA_DIR` +- `LITESTREAM_S3_BUCKET` +- `LITESTREAM_S3_PREFIX` +- `LITESTREAM_AWS_REGION` +- `LITESTREAM_S3_ENDPOINT` +- `LITESTREAM_S3_SKIP_VERIFY` +- `LITESTREAM_LOG_LEVEL` (defaults to `warn`) +- `LITESTREAM_ACCESS_KEY_ID` +- `LITESTREAM_SECRET_ACCESS_KEY` + +## App Setup To start your Phoenix server: diff --git a/TODO.md b/TODO.md index c223758..293f228 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ 3. [High] Execution runs under workflow owner scope, not triggering user scope. lib/fizz/runtime/execution/server.ex:271 builds runtime scope from execution.workflow.user and - workspace, while execution records track triggered_by_user_id from request time (lib/fizz/ + project, while execution records track triggered_by_user_id from request time (lib/fizz/ executions.ex:221). This can produce permission drift for preview/partial runs. Better pattern: resolve runtime scope from triggered_by_user_id when present, fallback to system @@ -114,4 +114,62 @@ for the demo we will show a workflow that constantly adds + 1 to a number stored simple email agent with human in the loop for deletion of emails -add a chat endpoint that can use natural language to determine which workflow to run based on the user's input. it will use workflow names, descriptions, and workspaces to determine the best workflow to run. could run multiple workflows - this is a ux feature \ No newline at end of file +add a chat endpoint that can use natural language to determine which workflow to run based on the user's input. it will use workflow names, descriptions, and projects to determine the best workflow to run. could run multiple workflows - this is a ux feature + + + +workflow example idea + +A bot that starts an a/b experiment and updates the experiments based on events and signals from Google Analytics or the website itself updating the experiment continuously. + + bot that can detect errors, triage them, and update the website, then emails the user the issue and the fix. + + + + - [ ] Users should be able to pin data without needing to run a node. they should be able to paste a payload of their shape + - [ ] add loop zone like ui from blender to spliter aggregate pairs. add ux that lets user paginate through the iterations + + + bugs + + +- When we open a a debug workflow mode by visting workflow//edit/runs/, it does not rebuild the graph from the logs, it shows the current a view with the currentdraft model ( have not tested with published workflows ) + + +what should the debug view show? what should the user experience be? how will users debug and pin workflows on published versions or previous drafts if they drift significantly from the current draft? + +c/workflows/\/edit/runs/648c061d-0b09-4cbe-9765-09fd578de200 + + +BUG +Pinned outputs dont actually work. they appear to work in the ui on the pinned node (showing the input and the correct pinned output) but when you look at the downstream nodes, they are using real live output, not the pinned output. + +we honestly shouldnt even be executing nodes with pinned outputs so how is this happening. + +- Research workflow execution check points during live runs +- Research post-terminal SQLite /litestream/minio compaction: checkpoint completed/cancelled/failed workflows +- Research workflow compute scaling: LeaseManager start-run contention, repo pool pressure, and Litestream memory/file-watch growth under high concurrency. + Current benchmark snapshot: simple runs sustained ~80 runs/s, 500 sleeping workers stayed stable, but 1000-run launch bursts degraded sharply and a 200-concurrent start burst timed out in LeaseManager.acquire/1. + Validate whether the next bottleneck is the single GenServer lease path, Repo pool_size=10 in dev, or Litestream recursively watching too many terminal SQLite files. +Should we merge all field, resolver, and input logic into the slot system? extending it where needed? + + +working on integrations, I was wondering if oauth was correct for some apps. +We currently support oauth for google, slack, notion, box, github, and custom api keys like openai, anthropic, etc. we do have api keys for platforms that also have oauth like microsoft, github, etc. + +but is this correct for all apps? what would it look like if users wanted make a slack/discord bot, what about a github app? what about google? + +add settings page, need to reconcile ux on how we we will display editor/owner settings like workflow name, deleting the workflow etc. and how to handle viewers/runners who need to manage things like what credentials they have bound. + + + +issues +- for fields like a object inut, we shouldnt show tree and table views, just json, that also means hide the otions/tab selectors +-rename structured schema sub node slot to schema +- do we need to differentiate between fixed and expression? might be unnecessary + - but what about special ui inputs like Row Values @MapEditor for google sheets append row? what if we dont want a special ui input and just want to send json, ex. you get a table structure from a upstream node, doesnt make sense to put that in a single cell via map editor input + map editor is already a "fixed" input but evaluates expressions inside the table. + +-cant ctrl c + p copy and paste from step config model + +- how should we organize custom ui? specific to a integration/provider like google \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index 69aae3f..8776955 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -4,6 +4,7 @@ tasks: init: desc: Initialize development environment cmds: + - docker compose -f docker-compose.resources.yml up -d - mix deps.get - mix ecto.create - mix ecto.migrate @@ -11,26 +12,26 @@ tasks: - mix assets.build up: - desc: Start development services (PostgreSQL + Adminer) + desc: Start development services (PostgreSQL + MinIO + Adminer) cmds: - - docker-compose -f docker-compose.resources.yml up -d + - docker compose -f docker-compose.resources.yml up -d down: desc: Stop development services cmds: - - docker-compose -f docker-compose.resources.yml down + - docker compose -f docker-compose.resources.yml down logs: desc: Show logs from development services cmds: - - docker-compose -f docker-compose.resources.yml logs -f + - docker compose -f docker-compose.resources.yml logs -f restart: desc: Restart development services cmds: - - docker-compose -f docker-compose.resources.yml restart + - docker compose -f docker-compose.resources.yml restart status: desc: Show status of development services cmds: - - docker-compose -f docker-compose.resources.yml ps \ No newline at end of file + - docker compose -f docker-compose.resources.yml ps diff --git a/assets/js/app.js b/assets/js/app.js index d7ca6f0..8394dc8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -28,7 +28,7 @@ import topbar from "topbar" import {getHooks} from "live_vue" import liveVueApp from "../vue" import {PipesWidget, WorkOSReactWidget} from "./hooks/workos_react_widgets" -import {SpriteConsole} from "./hooks/sprite_console" +import {WorkspaceConsole} from "./hooks/workspace_console" const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") @@ -40,7 +40,7 @@ const liveSocket = new LiveSocket("/live", Socket, { ...getHooks(liveVueApp), PipesWidget, WorkOSReactWidget, - SpriteConsole, + WorkspaceConsole, ScrollBottom: { updated() { this.el.scrollTop = this.el.scrollHeight } }, diff --git a/assets/js/hooks/workos_react_widgets.js b/assets/js/hooks/workos_react_widgets.js index 456ee5a..5a5ad4d 100644 --- a/assets/js/hooks/workos_react_widgets.js +++ b/assets/js/hooks/workos_react_widgets.js @@ -11,13 +11,25 @@ import { UsersManagement, WorkOsWidgets, } from "@workos-inc/widgets" +import {ApiProvider} from "../../../node_modules/@workos-inc/widgets/dist/esm/api/api-provider.js" +import {useMyDataIntegrations} from "../../../node_modules/@workos-inc/widgets/dist/esm/api/endpoint.js" +import {ErrorBoundary} from "../../../node_modules/@workos-inc/widgets/dist/esm/lib/error-boundary.js" +import { + Pipes as PipesList, + PipesError, + PipesLoading, +} from "../../../node_modules/@workos-inc/widgets/dist/esm/lib/pipes.js" +import {useIsHydrated} from "../../../node_modules/@workos-inc/widgets/dist/esm/lib/use-is-hydrated.js" +import {useWorkOsApiUrl} from "../../../node_modules/@workos-inc/widgets/dist/esm/lib/widgets-context.js" import "@radix-ui/themes/styles.css" import "@workos-inc/widgets/styles.css" const DEFAULT_WIDGET = "pipes" +const CONNECTION_REFRESH_DELAY_MS = 1200 const WIDGET_COMPONENTS = { pipes: Pipes, + "scoped-pipes": ScopedPipes, "organization-switcher": OrganizationSwitcher, "admin-portal-sso-connection": AdminPortalSsoConnection, "admin-portal-domain-verification": AdminPortalDomainVerification, @@ -44,6 +56,20 @@ function parseJson(rawValue, fallbackValue) { return fallbackValue } +function readArray(rawValue) { + const parsed = parseJson(rawValue, []) + + if (Array.isArray(parsed)) { + return parsed.filter(value => typeof value === "string" && value.trim() !== "") + } + + if (typeof parsed === "string" && parsed.trim() !== "") { + return [parsed.trim()] + } + + return [] +} + function readWidgetName(element, defaultWidget) { return (element.dataset.widget || defaultWidget || DEFAULT_WIDGET).trim().toLowerCase() } @@ -56,6 +82,12 @@ function readWidgetProps(element) { widgetProps.authToken = authToken } + const integrationSlugs = readArray(element.dataset.integrationSlugs) + + if (integrationSlugs.length > 0 && widgetProps.integrationSlugs == null) { + widgetProps.integrationSlugs = integrationSlugs + } + if (widgetProps.authToken == null) { throw new Error("Missing required WorkOS `authToken` for widget rendering") } @@ -67,7 +99,8 @@ function readWidgetRootProps(element) { return parseJson(element.dataset.workosProps, {}) } -function buildWidgetTree(element, defaultWidget) { +function buildWidgetTree(controller, defaultWidget) { + const {el: element} = controller const widgetName = readWidgetName(element, defaultWidget) const Widget = WIDGET_COMPONENTS[widgetName] @@ -78,6 +111,13 @@ function buildWidgetTree(element, defaultWidget) { const widgetRootProps = readWidgetRootProps(element) const widgetProps = readWidgetProps(element) + if ( + controller.__workosOnConnectionSettled && + widgetName === "scoped-pipes" + ) { + widgetProps.onConnectionSettled = controller.__workosOnConnectionSettled + } + return React.createElement( WorkOsWidgets, widgetRootProps, @@ -85,50 +125,228 @@ function buildWidgetTree(element, defaultWidget) { ) } -function renderWidget(hook, defaultWidget) { +function renderWidget(controller, defaultWidget) { try { - hook.__workosRoot.render(buildWidgetTree(hook.el, defaultWidget)) + controller.__workosRoot.render(buildWidgetTree(controller, defaultWidget)) } catch (error) { console.error("[WorkOSWidgetHook] Unable to render widget", error) - hook.el.dataset.widgetError = "true" - hook.el.innerHTML = + controller.el.dataset.widgetError = "true" + controller.el.innerHTML = '
' + "Unable to load the WorkOS widget." + "
" } } -function mountRoot(hook) { +function mountRoot(controller) { const reactRootElement = document.createElement("div") - reactRootElement.id = `${hook.el.id}-react-root` + reactRootElement.id = `${controller.el.id}-react-root` reactRootElement.className = "w-full" - hook.el.replaceChildren(reactRootElement) - hook.__workosRootElement = reactRootElement - hook.__workosRoot = createRoot(reactRootElement) + controller.el.replaceChildren(reactRootElement) + controller.__workosRootElement = reactRootElement + controller.__workosRoot = createRoot(reactRootElement) +} + +function unmountRoot(controller) { + if (controller.__workosRoot) { + controller.__workosRoot.unmount() + } + + controller.__workosRoot = null + controller.__workosRootElement = null +} + +function normalizeProviderSlugs(value) { + if (Array.isArray(value)) { + return value.filter(item => typeof item === "string" && item.trim() !== "") + } + + if (typeof value === "string" && value.trim() !== "") { + return [value.trim()] + } + + return [] +} + +function integrationMatchesProvider(integration, providerSlugs) { + if (providerSlugs.length === 0) { + return true + } + + const normalizedProviderSlugs = new Set(providerSlugs.map(slug => slug.toLowerCase())) + const integrationValues = [ + integration.slug, + integration.integrationSlug, + integration.integrationType, + integration.provider, + integration.providerSlug, + ] + + return integrationValues.some( + value => typeof value === "string" && normalizedProviderSlugs.has(value.toLowerCase()) + ) +} + +function ScopedPipes({ + authToken, + integrationSlug, + integrationSlugs, + onConnectionSettled, + ...domProps +}) { + const providerSlugs = React.useMemo( + () => + normalizeProviderSlugs(integrationSlugs).concat(normalizeProviderSlugs(integrationSlug)), + [integrationSlug, integrationSlugs] + ) + const baseUrl = useWorkOsApiUrl() + + return React.createElement( + ErrorBoundary, + { + fallbackRender: ({error}) => React.createElement(PipesError, {...domProps, error}), + }, + React.createElement( + ApiProvider, + {widgetType: "pipes", authToken, baseUrl}, + React.createElement(ScopedPipesImpl, { + ...domProps, + providerSlugs, + onConnectionSettled, + }) + ) + ) +} + +function ScopedPipesImpl({providerSlugs, onConnectionSettled, ...domProps}) { + const isHydrated = useIsHydrated() + const integrations = useMyDataIntegrations() + const notifyTimer = React.useRef(null) + const connectionPending = React.useRef(false) + + React.useEffect(() => { + return () => { + if (notifyTimer.current) { + window.clearTimeout(notifyTimer.current) + } + } + }, []) + + const notifyConnectionSettled = React.useCallback(() => { + if (!onConnectionSettled) { + return + } + + connectionPending.current = false + + if (notifyTimer.current) { + window.clearTimeout(notifyTimer.current) + } + + notifyTimer.current = window.setTimeout(() => { + integrations.refetch() + onConnectionSettled() + }, CONNECTION_REFRESH_DELAY_MS) + }, [integrations, onConnectionSettled]) + + const markPotentialConnection = React.useCallback(() => { + connectionPending.current = true + }, []) + + React.useEffect(() => { + if (!onConnectionSettled) { + return undefined + } + + const handleConnectionSignal = () => { + if (connectionPending.current) { + notifyConnectionSettled() + } + } + + window.addEventListener("focus", handleConnectionSignal) + window.addEventListener("message", handleConnectionSignal) + + return () => { + window.removeEventListener("focus", handleConnectionSignal) + window.removeEventListener("message", handleConnectionSignal) + } + }, [notifyConnectionSettled, onConnectionSettled]) + + if (!isHydrated || integrations.isLoading) { + return React.createElement(PipesLoading, { + count: Math.max(providerSlugs.length, 1), + ...domProps, + }) + } + + if (integrations.isError) { + return React.createElement(PipesError, {error: integrations.error, ...domProps}) + } + + if (integrations.isSuccess) { + const filteredIntegrations = (integrations.data.data || []).filter(integration => + integrationMatchesProvider(integration, providerSlugs) + ) + + return React.createElement( + "div", + {onClickCapture: markPotentialConnection}, + React.createElement(PipesList, { + integrations: filteredIntegrations, + ...domProps, + }) + ) + } + + return React.createElement(PipesError, {error: integrations.error, ...domProps}) } -function unmountRoot(hook) { - if (hook.__workosRoot) { - hook.__workosRoot.unmount() +export function mountWorkOSWidget(element, options = {}) { + const controller = { + el: element, + __workosRoot: null, + __workosRootElement: null, + __workosOnConnectionSettled: options.onConnectionSettled || null, } + const defaultWidget = options.defaultWidget || DEFAULT_WIDGET - hook.__workosRoot = null - hook.__workosRootElement = null + mountRoot(controller) + renderWidget(controller, defaultWidget) + + return { + update() { + controller.__workosOnConnectionSettled = options.onConnectionSettled || null + renderWidget(controller, defaultWidget) + }, + destroy() { + unmountRoot(controller) + }, + } } export function createWorkOSWidgetHook(defaultWidget = DEFAULT_WIDGET) { return { mounted() { - mountRoot(this) - renderWidget(this, defaultWidget) + this.__workosWidgetController = mountWorkOSWidget(this.el, { + defaultWidget, + onConnectionSettled: () => { + const event = this.el.dataset.connectedEvent + + if (event) { + this.pushEvent(event, {}) + } + }, + }) }, updated() { - renderWidget(this, defaultWidget) + this.__workosWidgetController?.update() }, destroyed() { - unmountRoot(this) + this.__workosWidgetController?.destroy() + this.__workosWidgetController = null }, } } diff --git a/assets/js/hooks/sprite_console.js b/assets/js/hooks/workspace_console.js similarity index 94% rename from assets/js/hooks/sprite_console.js rename to assets/js/hooks/workspace_console.js index aa1727a..6b58b37 100644 --- a/assets/js/hooks/sprite_console.js +++ b/assets/js/hooks/workspace_console.js @@ -12,7 +12,7 @@ function decodeBase64Chunk(encoded) { } } -export const SpriteConsole = { +export const WorkspaceConsole = { mounted() { this.consoleId = this.el.dataset.consoleId this.outputEl = this.el.querySelector("[data-console-output]") @@ -45,7 +45,7 @@ export const SpriteConsole = { }) this.socket = getUserSocket() - this.channel = this.socket.channel(`sprite_console:${this.consoleId}`, {}) + this.channel = this.socket.channel(`workspace_console:${this.consoleId}`, {}) this.channel .join() @@ -65,7 +65,7 @@ export const SpriteConsole = { this.channel.on("error", ({reason}) => this.term.writeln(`\r\n[error: ${reason}]`)) this.channel.on("closed", ({reason}) => this.term.writeln(`\r\n[closed: ${reason}]`)) - this.handleEvent("sprite_console_run_command", payload => { + this.handleEvent("workspace_console_run_command", payload => { if (!payload || typeof payload.command !== "string") { return } diff --git a/assets/public/images/microsoft_outlook.svg b/assets/public/images/microsoft_outlook.svg index 2cde22d..586f39f 100644 --- a/assets/public/images/microsoft_outlook.svg +++ b/assets/public/images/microsoft_outlook.svg @@ -1,35 +1,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/public/images/microsoft_sharepoint.svg b/assets/public/images/microsoft_sharepoint.svg index cf03d3e..c41647f 100644 --- a/assets/public/images/microsoft_sharepoint.svg +++ b/assets/public/images/microsoft_sharepoint.svg @@ -1,2 +1,2 @@ - + \ No newline at end of file diff --git a/assets/public/images/notion.svg b/assets/public/images/notion.svg index 32ea37a..c06a314 100644 --- a/assets/public/images/notion.svg +++ b/assets/public/images/notion.svg @@ -1,6 +1,6 @@ - - - - + + + + \ No newline at end of file diff --git a/assets/vite.config.mjs b/assets/vite.config.mjs index 5615a74..e215088 100644 --- a/assets/vite.config.mjs +++ b/assets/vite.config.mjs @@ -1,10 +1,25 @@ +import crypto from "crypto"; +import fs from "fs"; import { defineConfig } from 'vite' import path from 'path'; import vue from "@vitejs/plugin-vue"; import liveVuePlugin from "live_vue/vitePlugin"; import tailwindcss from "@tailwindcss/vite"; +const viteCacheKey = crypto + .createHash("sha256") + .update( + ["../mix.lock", "../package-lock.json"] + .map((file) => fs.readFileSync(path.resolve(__dirname, file), "utf8")) + .join("\n"), + ) + .digest("hex") + .slice(0, 8); + export default defineConfig({ + // Linked Phoenix JS packages live under ../deps, so key the Vite cache to the + // lockfiles that change when those versions change. + cacheDir: path.resolve(__dirname, `../node_modules/.vite-${viteCacheKey}`), server: { host: "127.0.0.1", port: 5173, diff --git a/assets/vue/RevisionViewer.vue b/assets/vue/RevisionViewer.vue index 844f113..7665b71 100644 --- a/assets/vue/RevisionViewer.vue +++ b/assets/vue/RevisionViewer.vue @@ -1,12 +1,13 @@
- +
@@ -45,7 +58,7 @@ const noop = () => {};
@@ -75,10 +88,10 @@ const noop = () => {};
- {{ (props.workflow as any)?.workspace?.name || 'Workspace' }} + {{ (props.workflow as any)?.project?.name || 'project' }} @@ -110,6 +123,7 @@ const noop = () => {}; :set-canvas-ref="viewer.setCanvasRef" :set-vue-flow-ref="viewer.setVueFlowRef" :handle-pane-mouse-move="noopMouse" + :handle-pane-mouse-leave="noop" :handle-node-click="viewer.handleNodeClick" :handle-node-double-click="viewer.handleNodeDoubleClick" :handle-node-context-menu="noopNodeMouse" @@ -163,7 +177,7 @@ const noop = () => {}; ? 'border-primary/40 bg-primary/10 text-primary' : 'border-base-200 hover:border-base-300 hover:bg-base-200/60', ]" - @click="emit('select_revision', { kind: 'current' })" + @click="selectRevision({ kind: 'current' })" >
Current draft
@@ -171,8 +185,8 @@ const noop = () => {}; Last updated {{ viewer.formatRevisionTimestamp(viewer.workflowUpdatedAt) }}
- - v{{ props.workflow.current_version_tag }} + + v{{ props.draft.version }} @@ -197,7 +211,7 @@ const noop = () => {}; ? 'border-primary/40 bg-primary/10 text-primary' : 'border-base-200 hover:border-base-300 hover:bg-base-200/60', ]" - @click="emit('select_revision', { kind: 'undo', depth: entry.depth })" + @click="selectRevision({ kind: 'undo', depth: entry.depth })" >
@@ -234,16 +248,16 @@ const noop = () => {}; ? 'border-primary/40 bg-primary/10 text-primary' : 'border-base-200 hover:border-base-300 hover:bg-base-200/60', ]" - @click="emit('select_revision', { kind: 'version', id: version.id })" + @click="selectRevision({ kind: 'version', id: version.id })" >
-
v{{ version.version_tag }}
+
v{{ version.version }}
{{ viewer.formatRevisionTimestamp(version.published_at) }}
Current diff --git a/assets/vue/WorkflowEditor.vue b/assets/vue/WorkflowEditor.vue index 2a16ed1..c954812 100644 --- a/assets/vue/WorkflowEditor.vue +++ b/assets/vue/WorkflowEditor.vue @@ -1,11 +1,12 @@ @@ -347,7 +601,7 @@ useLiveEvent<{ success: boolean; error?: string }>( v-if="!isNodeLibraryCollapsed" :library-items="editor.nodeLibraryItems" :workflow-name="editor.workflow?.name ?? 'Untitled Workflow'" - :workflow-status="editor.workflow?.status ?? 'draft'" + :workflow-status="workflowStatus" :style="{ width: `${nodeLibraryWidth}px` }" class="z-20 shrink-0 relative" @resize-start="handleNodeLibraryResizeStart" @@ -388,13 +642,12 @@ useLiveEvent<{ success: boolean; error?: string }>(
( - + + {{ saveIndicatorDetail }} +

(
+ +
+
+ + {{ inlineValidationTitle }} + · + {{ inlineValidationErrors.length }} issues +
+
    +
  • + {{ formatValidationError(error) }} +
  • +
+

+ + {{ inlineValidationErrors.length - 3 }} more +

+
@@ -497,6 +777,7 @@ useLiveEvent<{ success: boolean; error?: string }>( :set-canvas-ref="editor.setCanvasRef" :set-vue-flow-ref="editor.setVueFlowRef" :handle-pane-mouse-move="editor.handlePaneMouseMove" + :handle-pane-mouse-leave="editor.handlePaneMouseLeave" :handle-node-click="editor.handleNodeClick" :handle-node-double-click="editor.handleNodeDoubleClick" :handle-node-context-menu="editor.handleNodeContextMenu" @@ -551,7 +832,6 @@ useLiveEvent<{ success: boolean; error?: string }>( @run_node="editor.handleRunNode" @pin_output="editor.handlePinOutput" @unpin_output="editor.handleUnpinOutput" - @toggle_webhook_test="editor.handleToggleWebhookTest" /> ( + +
diff --git a/assets/vue/components/flow/CollaborativeCursors.vue b/assets/vue/components/flow/CollaborativeCursors.vue index fd51568..8af8b9c 100644 --- a/assets/vue/components/flow/CollaborativeCursors.vue +++ b/assets/vue/components/flow/CollaborativeCursors.vue @@ -41,12 +41,13 @@ const getCursorStyle = (presence: UserPresence) => { return { // Position the tip of the arrow at the cursor position - transform: `translate(${presence.cursor.x}px, ${presence.cursor.y}px) scale(${inverseZoom})`, + transform: `translate3d(${presence.cursor.x}px, ${presence.cursor.y}px, 0) scale(${inverseZoom})`, // Smooth cursor movement with short transition // We include the scale in transition to keep it smooth if zoom changes transition: 'transform 100ms ease-out', willChange: 'transform', transformOrigin: '0 0', + backfaceVisibility: 'hidden', }; }; @@ -62,40 +63,22 @@ const getUserDisplayName = (user: UserPresence['user']) => { diff --git a/assets/vue/components/flow/ExecutionTracePanel.vue b/assets/vue/components/flow/ExecutionTracePanel.vue index d9d2e01..1971c25 100644 --- a/assets/vue/components/flow/ExecutionTracePanel.vue +++ b/assets/vue/components/flow/ExecutionTracePanel.vue @@ -14,6 +14,7 @@ import { StopIcon, } from '@heroicons/vue/24/outline'; import { unwrapData } from '@/lib/dataUtils'; +import { buildStepRootPath } from '@/lib/expressionPath'; import DataViewer from '@/components/ui/data-viewer/DataViewer.vue'; // Props from LiveView @@ -106,6 +107,17 @@ const formatTraceTimestamp = (execution: StepExecution): string => { return ''; }; +const hasDuplicateStepName = (stepName?: string | null) => { + if (typeof stepName !== 'string' || stepName.trim().length === 0) { + return false; + } + + return Object.values(props.stepNameById).filter(name => name === stepName).length > 1; +}; + +const outputRootPath = (stepName?: string | null, stepId?: string | null) => + buildStepRootPath(hasDuplicateStepName(stepName) ? undefined : stepName, stepId); + // Computed traces from props or mock const traces = computed(() => { if (props.stepExecutions.length > 0) { @@ -117,11 +129,12 @@ const traces = computed(() => { const result: TraceEntry[] = []; - for (const [stepId, executions] of executionsByStep.entries()) { + for (const [stepId, executions] of Array.from(executionsByStep.entries())) { const firstExecution = executions[0]; const baseName = props.stepNameById?.[stepId] || stepId; - const isMultiItem = firstExecution.items_total && firstExecution.items_total > 1; + const itemsTotal = firstExecution.items_total ?? executions.length; + const isMultiItem = itemsTotal > 1 || executions.length > 1; if (isMultiItem) { const completedCount = executions.filter(se => se.status === 'completed').length; @@ -151,7 +164,7 @@ const traces = computed(() => { duration_us: totalDuration, timestamp: formatTraceTimestamp(earliestExecution), item_index: null, - items_total: firstExecution.items_total, + items_total: itemsTotal, isMultiItem: true, iterations: executions .map(se => ({ @@ -198,9 +211,15 @@ const traces = computed(() => { return []; }); +type TracePanelStatus = ExecutionStatus | 'idle'; + // Execution status -const executionStatus = computed(() => props.execution?.status ?? 'pending'); -const isRunning = computed(() => executionStatus.value === 'running' || executionStatus.value === 'pending'); +const executionStatus = computed(() => props.execution?.status ?? 'idle'); +const isRunning = computed( + () => + !!props.execution && + (executionStatus.value === 'running' || executionStatus.value === 'pending') +); // Status counts (iteration-aware) const statusCounts = computed(() => { @@ -225,7 +244,8 @@ const statusCounts = computed(() => { }); // Minimal status badge config -const statusBadgeConfig: Record = { +const statusBadgeConfig: Record = { + idle: { class: 'bg-base-200 text-base-content/60', label: 'Idle' }, pending: { class: 'bg-base-200 text-base-content/70', label: 'Pending' }, running: { class: 'bg-primary/15 text-primary', label: 'Running' }, paused: { class: 'bg-warning/15 text-warning', label: 'Paused' }, @@ -251,10 +271,18 @@ const stepStatusClass = (status: StepExecutionStatus): string => { // Format duration const formatDuration = (us?: number): string => { - if (typeof us !== 'number' || !Number.isFinite(us) || us <= 0) return ''; + if (typeof us !== 'number' || !Number.isFinite(us) || us < 0) return ''; + if (us <= 0) return '<1µs'; if (us < 1000) return `${us}µs`; if (us < 1_000_000) return `${(us / 1000).toFixed(1)}ms`; - return `${(us / 1_000_000).toFixed(2)}s`; + const seconds = us / 1_000_000; + if (seconds < 60) return `${seconds.toFixed(2)}s`; + const minutes = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + if (minutes < 60) return `${minutes}m ${secs}s`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours}h ${mins}m`; }; const selectStep = (stepId: string) => { @@ -522,7 +550,7 @@ const stepMetaLine = (trace: TraceEntry) => {
@@ -551,7 +579,7 @@ const stepMetaLine = (trace: TraceEntry) => {
diff --git a/assets/vue/components/flow/Node.vue b/assets/vue/components/flow/Node.vue index 8b99c02..a3ebedb 100644 --- a/assets/vue/components/flow/Node.vue +++ b/assets/vue/components/flow/Node.vue @@ -5,7 +5,12 @@ import type { NodeProps } from '@vue-flow/core'; import Handle from './Handle.vue'; import { colorMap, type NodeStatus, oklchToHex, darkenColor, lightenColor } from '@/lib/color'; import { useThemeStore } from '@/stores/theme'; -import type { StepHandleQuickAddRequest, StepNodeData, StepSubnodeSlot } from '@/types/workflow'; +import type { + StepHandleQuickAddRequest, + StepInputHandle, + StepNodeData, + WorkflowValidationError, +} from '@/types/workflow'; import { GlobeAltIcon, ServerIcon, @@ -46,7 +51,7 @@ import { PlayIcon, BookmarkIcon as BookmarkSolidIcon } from '@heroicons/vue/24/s const props = defineProps>(); const themeStore = useThemeStore(); const canEdit = computed(() => props.data.canEdit ?? true); -const isSubnode = computed(() => props.data.node_role === 'subnode'); +const isSubnode = computed(() => false); const isEditing = ref(false); const nameDraft = ref(props.data.name || 'Untitled Step'); @@ -177,6 +182,17 @@ const statusConfig = computed(() => { }); const currentStatusStyle = computed(() => statusConfig.value[effectiveStatus.value]); +const validationErrors = computed(() => props.data.validation_errors ?? []); +const hasValidationErrors = computed(() => validationErrors.value.length > 0); +const validationErrorCount = computed(() => validationErrors.value.length); +const validationTooltip = computed(() => + validationErrors.value + .map(error => { + const location = error.field ? `${error.field}: ` : ''; + return `${location}${error.message}`; + }) + .join('\n') +); const hexToRgba = (hex: string, alpha: number) => { const normalized = hex.replace('#', ''); @@ -249,6 +265,15 @@ const nodeStyle = computed(() => { : currentStatusStyle.value.border; } + if (hasValidationErrors.value) { + const errorColor = oklchToHex(colorMap.failed); + shadow += `, 0 0 0 2px ${hexToRgba(errorColor, isDark ? 0.22 : 0.14)}`; + + if (!hasStatusStyle.value) { + style.borderColor = hexToRgba(errorColor, isDark ? 0.7 : 0.5); + } + } + if (props.data.selected_by?.length) { style['--tw-ring-color'] = props.data.selected_by[0].color; } @@ -259,9 +284,17 @@ const nodeStyle = computed(() => { // Format duration for display const formatDuration = (us?: number): string => { if (typeof us !== 'number' || !Number.isFinite(us)) return '—'; + if (us <= 0) return '<1µs'; if (us < 1000) return `${us}µs`; if (us < 1_000_000) return `${(us / 1000).toFixed(1)}ms`; - return `${(us / 1_000_000).toFixed(2)}s`; + const seconds = us / 1_000_000; + if (seconds < 60) return `${seconds.toFixed(2)}s`; + const minutes = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + if (minutes < 60) return `${minutes}m ${secs}s`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours}h ${mins}m`; }; // Format bytes for display @@ -284,9 +317,9 @@ const showInputHandle = computed( () => props.data.hasInput !== false && props.data.step_kind !== 'trigger' ); const showOutputHandle = computed(() => props.data.hasOutput !== false); -const subnodeInputHandles = computed(() => { - const slots = props.data.subnode_slots ?? []; - return slots.filter(slot => slot.id && slot.id !== 'main'); +const dependencyInputHandles = computed(() => { + const inputs = props.data.dependency_inputs ?? []; + return inputs.filter(input => input.id && input.id !== 'main'); }); const handleOutputQuickAdd = (screenPoint: { x: number; y: number }) => { @@ -306,22 +339,22 @@ const handleOutputQuickAdd = (screenPoint: { x: number; y: number }) => { props.data.onHandleQuickAdd?.(request); }; -const handleSubnodeSlotQuickAdd = ( - slot: StepSubnodeSlot, +const handleDependencyInputQuickAdd = ( + input: StepInputHandle, screenPoint: { x: number; y: number } ) => { if (!canEdit.value) return; - const acceptedTypeIds = slot.accepts?.type_ids ?? []; + const acceptedProvides = input.accepts?.provides ?? []; const request: StepHandleQuickAddRequest = { screenPoint, autoConnect: { target_step_id: props.id, - target_input: slot.id, + target_input: input.id, }, filter: { - mode: 'subnode_slot', - accepted_type_ids: acceptedTypeIds.length > 0 ? acceptedTypeIds : undefined, + mode: 'dependency_input', + accepted_provides: acceptedProvides.length > 0 ? acceptedProvides : undefined, }, }; @@ -430,27 +463,27 @@ const handleNameKeydown = (event: KeyboardEvent) => {
- -