From b850e60fd1dba8a77d8dff589b6fc06866532b66 Mon Sep 17 00:00:00 2001 From: Galad Dirie Date: Thu, 5 Mar 2026 09:01:38 -0500 Subject: [PATCH 001/135] remove old code --- assets/vue/WorkflowEditor.vue | 1 - .../flow/step_config/StepConfigConfigPane.vue | 116 - .../flow/step_config/StepConfigModal.vue | 1 - .../flow/step_config/useStepConfig.ts | 10 - .../flow/step_config/useWebhookTest.ts | 103 - .../workflow/useWorkflowActions.ts | 8 - .../composables/workflow/useWorkflowEditor.ts | 1 - assets/vue/types/workflow.ts | 8 - assets/vue/types/workflowEditor.ts | 5 - lib/fizz/application.ex | 9 - lib/fizz/collaboration/edit_operation.ex | 68 - .../collaboration/edit_session/inversion.ex | 400 --- .../collaboration/edit_session/operations.ex | 1170 -------- .../collaboration/edit_session/persistence.ex | 137 - .../collaboration/edit_session/presence.ex | 249 -- .../collaboration/edit_session/pub_sub.ex | 121 - .../collaboration/edit_session/registry.ex | 6 - lib/fizz/collaboration/edit_session/server.ex | 1262 --------- .../collaboration/edit_session/supervisor.ex | 45 - .../collaboration/edit_session/undo_entry.ex | 15 - .../collaboration/edit_session/undo_stack.ex | 46 - lib/fizz/collaboration/editor_state.ex | 193 -- .../collaboration/editor_state_encoder.ex | 13 - lib/fizz/collaboration/preview_execution.ex | 210 -- lib/fizz/executions.ex | 2 +- lib/fizz/executions/events.ex | 2 +- lib/fizz/runtime/execution/server.ex | 531 ---- lib/fizz/runtime/execution/supervisor.ex | 34 - lib/fizz/runtime/execution_context.ex | 125 - lib/fizz/runtime/expression.ex | 2 +- lib/fizz/runtime/expression/context.ex | 36 +- lib/fizz/runtime/hooks/observability.ex | 459 ---- lib/fizz/runtime/runic_adapter.ex | 1100 -------- lib/fizz/runtime/step_execution_state.ex | 76 - lib/fizz/runtime/steps/step_runner.ex | 325 --- lib/fizz/runtime/triggers/activator.ex | 97 - lib/fizz/runtime/triggers/registry.ex | 173 -- lib/fizz/runtime/triggers/schedule_manager.ex | 41 - lib/fizz/{runtime => }/serializer.ex | 4 +- lib/fizz/steps/executors/ai_agent.ex | 22 +- .../steps/executors/respond_to_webhook.ex | 62 - lib/fizz/steps/executors/webhook_trigger.ex | 157 -- lib/fizz/steps/registry.ex | 2 - lib/fizz/workers/execution_worker.ex | 203 -- lib/fizz/workers/scheduled_trigger_worker.ex | 48 - lib/fizz/workflows.ex | 55 +- lib/fizz/workflows/contract.ex | 2 +- lib/fizz_web/formatters.ex | 2 - lib/fizz_web/live/execution_live/show.ex | 20 +- lib/fizz_web/live/workflow_live/edit.ex | 2436 ----------------- .../live/workflow_live/edit/command.ex | 54 - .../edit/edit_step_projection.ex | 242 -- .../workflow_live/edit/expression_preview.ex | 73 - .../workflow_live/edit/presence_formatter.ex | 25 - lib/fizz_web/live/workflow_live/index.ex | 2 +- lib/fizz_web/live/workflow_live/paths.ex | 18 - lib/fizz_web/live/workflow_live/revision.ex | 457 ---- lib/fizz_web/live/workflow_live/show.ex | 9 - lib/fizz_web/plugs/webhook_handler.ex | 322 --- lib/fizz_web/router.ex | 5 - .../inversion_commit_drag_layout_test.exs | 153 -- .../inversion_update_step_positions_test.exs | 70 - .../operations_add_step_group_resize_test.exs | 107 - .../operations_commit_drag_layout_test.exs | 225 -- .../edit_session/operations_subnodes_test.exs | 104 - .../operations_update_step_positions_test.exs | 87 - .../server_commit_drag_layout_test.exs | 167 -- .../edit_session/server_persistence_test.exs | 86 - .../edit_session/supervisor_test.exs | 58 - test/fizz/runtime/runic_adapter_test.exs | 233 -- test/fizz/runtime/steps/step_runner_test.exs | 183 -- .../live/workflow_execution_live_test.exs | 15 +- .../edit_add_step_auto_connect_test.exs | 255 -- .../edit_commit_drag_layout_test.exs | 194 -- .../edit_step_projection_test.exs | 111 - test/fizz_web/live/workflow_live_test.exs | 16 - 76 files changed, 55 insertions(+), 13429 deletions(-) delete mode 100644 assets/vue/components/flow/step_config/useWebhookTest.ts delete mode 100644 lib/fizz/collaboration/edit_operation.ex delete mode 100644 lib/fizz/collaboration/edit_session/inversion.ex delete mode 100644 lib/fizz/collaboration/edit_session/operations.ex delete mode 100644 lib/fizz/collaboration/edit_session/persistence.ex delete mode 100644 lib/fizz/collaboration/edit_session/presence.ex delete mode 100644 lib/fizz/collaboration/edit_session/pub_sub.ex delete mode 100644 lib/fizz/collaboration/edit_session/registry.ex delete mode 100644 lib/fizz/collaboration/edit_session/server.ex delete mode 100644 lib/fizz/collaboration/edit_session/supervisor.ex delete mode 100644 lib/fizz/collaboration/edit_session/undo_entry.ex delete mode 100644 lib/fizz/collaboration/edit_session/undo_stack.ex delete mode 100644 lib/fizz/collaboration/editor_state.ex delete mode 100644 lib/fizz/collaboration/editor_state_encoder.ex delete mode 100644 lib/fizz/collaboration/preview_execution.ex delete mode 100644 lib/fizz/runtime/execution/server.ex delete mode 100644 lib/fizz/runtime/execution/supervisor.ex delete mode 100644 lib/fizz/runtime/execution_context.ex delete mode 100644 lib/fizz/runtime/hooks/observability.ex delete mode 100644 lib/fizz/runtime/runic_adapter.ex delete mode 100644 lib/fizz/runtime/step_execution_state.ex delete mode 100644 lib/fizz/runtime/steps/step_runner.ex delete mode 100644 lib/fizz/runtime/triggers/activator.ex delete mode 100644 lib/fizz/runtime/triggers/registry.ex delete mode 100644 lib/fizz/runtime/triggers/schedule_manager.ex rename lib/fizz/{runtime => }/serializer.ex (95%) delete mode 100644 lib/fizz/steps/executors/respond_to_webhook.ex delete mode 100644 lib/fizz/steps/executors/webhook_trigger.ex delete mode 100644 lib/fizz/workers/execution_worker.ex delete mode 100644 lib/fizz/workers/scheduled_trigger_worker.ex delete mode 100644 lib/fizz_web/live/workflow_live/edit.ex delete mode 100644 lib/fizz_web/live/workflow_live/edit/command.ex delete mode 100644 lib/fizz_web/live/workflow_live/edit/edit_step_projection.ex delete mode 100644 lib/fizz_web/live/workflow_live/edit/expression_preview.ex delete mode 100644 lib/fizz_web/live/workflow_live/edit/presence_formatter.ex delete mode 100644 lib/fizz_web/live/workflow_live/revision.ex delete mode 100644 lib/fizz_web/plugs/webhook_handler.ex delete mode 100644 test/fizz/collaboration/edit_session/inversion_commit_drag_layout_test.exs delete mode 100644 test/fizz/collaboration/edit_session/inversion_update_step_positions_test.exs delete mode 100644 test/fizz/collaboration/edit_session/operations_add_step_group_resize_test.exs delete mode 100644 test/fizz/collaboration/edit_session/operations_commit_drag_layout_test.exs delete mode 100644 test/fizz/collaboration/edit_session/operations_subnodes_test.exs delete mode 100644 test/fizz/collaboration/edit_session/operations_update_step_positions_test.exs delete mode 100644 test/fizz/collaboration/edit_session/server_commit_drag_layout_test.exs delete mode 100644 test/fizz/collaboration/edit_session/server_persistence_test.exs delete mode 100644 test/fizz/collaboration/edit_session/supervisor_test.exs delete mode 100644 test/fizz/runtime/runic_adapter_test.exs delete mode 100644 test/fizz/runtime/steps/step_runner_test.exs delete mode 100644 test/fizz_web/live/workflow_live/edit_add_step_auto_connect_test.exs delete mode 100644 test/fizz_web/live/workflow_live/edit_commit_drag_layout_test.exs delete mode 100644 test/fizz_web/live/workflow_live/edit_step_projection_test.exs diff --git a/assets/vue/WorkflowEditor.vue b/assets/vue/WorkflowEditor.vue index 2a16ed1..7bb3def 100644 --- a/assets/vue/WorkflowEditor.vue +++ b/assets/vue/WorkflowEditor.vue @@ -551,7 +551,6 @@ useLiveEvent<{ success: boolean; error?: string }>( @run_node="editor.handleRunNode" @pin_output="editor.handlePinOutput" @unpin_output="editor.handleUnpinOutput" - @toggle_webhook_test="editor.handleToggleWebhookTest" /> import { inject } from 'vue'; import { - LinkIcon, - DocumentDuplicateIcon, - SignalIcon, CheckCircleIcon, } from '@heroicons/vue/24/outline'; import FieldWrapper from '../fields/FieldWrapper.vue'; @@ -22,119 +19,6 @@ const state = inject(StepConfigKey)!; - -
-
-

- - Webhook URL -

- - -
- - -
-
- - -
-
- {{ state.webhookMethod.value }} -
- -
- -
-
- - -
- - - -
-
-

- Listening for test event. Send a - {{ state.webhookMethod.value }} - request to the Test URL. -

-
-
-
-

- Another webhook trigger is already listening. Stop it to enable this one. -

-
-
-
-
diff --git a/assets/vue/components/flow/step_config/StepConfigModal.vue b/assets/vue/components/flow/step_config/StepConfigModal.vue index 4f7fef1..5d1883b 100644 --- a/assets/vue/components/flow/step_config/StepConfigModal.vue +++ b/assets/vue/components/flow/step_config/StepConfigModal.vue @@ -35,7 +35,6 @@ const emit = defineEmits([ 'close', 'save', 'preview_expression', - 'toggle_webhook_test', 'pin_output', 'unpin_output', 'run_node', diff --git a/assets/vue/components/flow/step_config/useStepConfig.ts b/assets/vue/components/flow/step_config/useStepConfig.ts index 799cc16..adc25fd 100644 --- a/assets/vue/components/flow/step_config/useStepConfig.ts +++ b/assets/vue/components/flow/step_config/useStepConfig.ts @@ -20,7 +20,6 @@ import { useExpressionPreviews } from './useExpressionPreviews'; import { useSubnodes } from './useSubnodes'; import { useStepExecution } from './useStepExecution'; import { usePinnedOutputs } from './usePinnedOutputs'; -import { useWebhookTest } from './useWebhookTest'; import { useInputData } from './useInputData'; import { useExpressionHelpers } from './useExpressionHelpers'; import { useContextExplorer } from './useContextExplorer'; @@ -257,14 +256,6 @@ export function useStepConfig(props: UseStepConfigProps, emit: (...args: any[]) emit, }); - const webhook = useWebhookTest({ - node: () => props.node, - editorState: () => props.editorState, - canEdit, - fieldValues, - emit, - }); - const inputData = useInputData({ node: () => props.node, execution: () => props.execution, @@ -324,7 +315,6 @@ export function useStepConfig(props: UseStepConfigProps, emit: (...args: any[]) ...subnodes, ...executionPublic, ...pinned, - ...webhook, ...inputData, ...expressionHelpers, ...contextExplorer, diff --git a/assets/vue/components/flow/step_config/useWebhookTest.ts b/assets/vue/components/flow/step_config/useWebhookTest.ts deleted file mode 100644 index b530855..0000000 --- a/assets/vue/components/flow/step_config/useWebhookTest.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { ref, computed, type ComputedRef, type Ref } from 'vue'; -import type { Node } from '@vue-flow/core'; -import type { EditorState, StepNodeData } from '@/types/workflow'; - -interface UseWebhookTestOptions { - node: () => Node | null; - editorState: () => EditorState | undefined; - canEdit: ComputedRef; - fieldValues: Ref>; - emit: (...args: any[]) => void; -} - -export function useWebhookTest({ - node, - editorState, - canEdit, - fieldValues, - emit, -}: UseWebhookTestOptions) { - const webhookMode = ref<'test' | 'production'>('test'); - - const isWebhookTrigger = computed(() => { - const typeId = node()?.data?.type_id; - return typeId === 'webhook_trigger' || typeId === 'webhook'; - }); - - const webhookPath = computed(() => { - const n = node(); - if (!n) return ''; - const rawPath = fieldValues.value?.path || n.data?.config?.path; - const path = typeof rawPath === 'string' ? rawPath.trim() : ''; - return path.length > 0 ? path : n.id; - }); - - const webhookMethod = computed(() => { - const rawMethod = node()?.data?.config?.http_method; - if (typeof rawMethod === 'string' && rawMethod.trim().length > 0) { - return rawMethod.trim().toUpperCase(); - } - return 'POST'; - }); - - const webhookTestState = computed(() => editorState()?.webhook_test || null); - - const isWebhookListening = computed(() => { - const n = node(); - if (!n || !webhookTestState.value) return false; - if (webhookTestState.value.step_id) { - return webhookTestState.value.step_id === n.id; - } - return webhookTestState.value.path === webhookPath.value; - }); - - const isWebhookListeningElsewhere = computed(() => { - const n = node(); - if (!n || !webhookTestState.value) return false; - return webhookTestState.value.step_id - ? webhookTestState.value.step_id !== n.id - : webhookTestState.value.path !== webhookPath.value; - }); - - const webhookUrl = computed(() => { - const n = node(); - if (!n) return ''; - const path = webhookPath.value; - const baseUrl = window.location.origin; - - if (webhookMode.value === 'test') { - return `${baseUrl}/api/hook-test/${path}`; - } else { - return `${baseUrl}/api/hooks/${path}`; - } - }); - - const copyWebhookUrl = () => { - navigator.clipboard.writeText(webhookUrl.value); - }; - - const toggleWebhookListening = () => { - if (!canEdit.value) return; - const n = node(); - if (!n || isWebhookListeningElsewhere.value) return; - emit('toggle_webhook_test', { - action: isWebhookListening.value ? 'stop' : 'start', - step_id: n.id, - path: webhookPath.value, - method: webhookMethod.value, - }); - }; - - return { - webhookMode, - isWebhookTrigger, - webhookPath, - webhookMethod, - webhookTestState, - isWebhookListening, - isWebhookListeningElsewhere, - webhookUrl, - copyWebhookUrl, - toggleWebhookListening, - }; -} diff --git a/assets/vue/composables/workflow/useWorkflowActions.ts b/assets/vue/composables/workflow/useWorkflowActions.ts index cce02f3..76d2650 100644 --- a/assets/vue/composables/workflow/useWorkflowActions.ts +++ b/assets/vue/composables/workflow/useWorkflowActions.ts @@ -44,13 +44,6 @@ export function useWorkflowActions(options: UseWorkflowActionsOptions) { expression: string; }) => options.emit('preview_expression', payload); - const handleToggleWebhookTest = (payload: { - step_id: string; - action: 'start' | 'stop'; - path?: string; - method?: string; - }) => options.emit('toggle_webhook_test', payload); - const selectTraceStep = (stepId: string) => { options.selectNode(stepId); }; @@ -62,7 +55,6 @@ export function useWorkflowActions(options: UseWorkflowActionsOptions) { handleRunTest, handleCancelExecution, handlePreviewExpression, - handleToggleWebhookTest, selectTraceStep, }; } diff --git a/assets/vue/composables/workflow/useWorkflowEditor.ts b/assets/vue/composables/workflow/useWorkflowEditor.ts index 71929d2..370438e 100644 --- a/assets/vue/composables/workflow/useWorkflowEditor.ts +++ b/assets/vue/composables/workflow/useWorkflowEditor.ts @@ -551,7 +551,6 @@ export function useWorkflowEditor(props: WorkflowEditorProps, emit: WorkflowEdit handleDeleteStep: actions.handleDeleteStep, handleSave: actions.handleSave, handlePreviewExpression: actions.handlePreviewExpression, - handleToggleWebhookTest: actions.handleToggleWebhookTest, handlePinOutput: pins.handlePinOutput, handleUnpinOutput: pins.handleUnpinOutput, selectTraceStep: actions.selectTraceStep, diff --git a/assets/vue/types/workflow.ts b/assets/vue/types/workflow.ts index e3f670d..f2d5234 100644 --- a/assets/vue/types/workflow.ts +++ b/assets/vue/types/workflow.ts @@ -370,14 +370,6 @@ export interface EditorState { pinned_outputs?: Record; disabled_steps?: string[]; step_locks?: Record; - webhook_test?: WebhookTestState | null; -} - -export interface WebhookTestState { - step_id?: string; - path: string; - method?: string; - enabled_by?: string; } // ============================================================================= diff --git a/assets/vue/types/workflowEditor.ts b/assets/vue/types/workflowEditor.ts index 876ffca..42712b6 100644 --- a/assets/vue/types/workflowEditor.ts +++ b/assets/vue/types/workflowEditor.ts @@ -61,7 +61,6 @@ export type WorkflowEditorCommandType = | 'mouse_move' | 'selection_changed' | 'preview_expression' - | 'toggle_webhook_test' | 'navigate_revisions'; export interface WorkflowEditorCommand { @@ -192,9 +191,5 @@ export type WorkflowEditorEmits = { e: 'preview_expression', payload: { step_id: string; field_key: string; expression: string } ): void; - ( - e: 'toggle_webhook_test', - payload: { step_id: string; action: 'start' | 'stop'; path?: string; method?: string } - ): void; (e: 'navigate_revisions'): void; }; diff --git a/lib/fizz/application.ex b/lib/fizz/application.ex index 0c1bca5..01fcbbc 100644 --- a/lib/fizz/application.ex +++ b/lib/fizz/application.ex @@ -18,16 +18,7 @@ defmodule Fizz.Application do # Step type registry - must start before endpoint so types are available Fizz.Steps.Registry, - {Registry, keys: :unique, name: Fizz.Runtime.Execution.Registry}, - {Task.Supervisor, name: Fizz.Runtime.Execution.TaskSupervisor}, - Fizz.Runtime.Execution.Supervisor, Fizz.Runtime.Expression.Cache, - # Trigger runtime - Fizz.Runtime.Triggers.Registry, - # Collaboration modules - {Registry, keys: :unique, name: Fizz.Collaboration.EditSession.Registry}, - Fizz.Collaboration.EditSession.Supervisor, - {Fizz.Collaboration.EditSession.Presence, []}, # Start a worker by calling: Fizz.Worker.start_link(arg) # {Fizz.Worker, arg}, diff --git a/lib/fizz/collaboration/edit_operation.ex b/lib/fizz/collaboration/edit_operation.ex deleted file mode 100644 index 61769e5..0000000 --- a/lib/fizz/collaboration/edit_operation.ex +++ /dev/null @@ -1,68 +0,0 @@ -defmodule Fizz.Collaboration.EditOperation do - @moduledoc """ - Persisted edit operation for audit trail and recovery. - """ - use Fizz.Schema - - @type op_type :: - :add_step - | :remove_step - | :update_step_config - | :update_step_position - | :update_step_positions - | :update_step_metadata - | :add_connection - | :remove_connection - | :add_group - | :update_group - | :remove_group - | :set_group_membership - | :commit_drag_layout - | :pin_step_output - | :unpin_step_output - | :disable_step - | :enable_step - - schema "edit_operations" do - field :operation_id, :string - # Server-assigned sequence - field :seq, :integer - - field :type, Ecto.Enum, - values: [ - :add_step, - :remove_step, - :update_step_config, - :update_step_position, - :update_step_positions, - :update_step_metadata, - :add_connection, - :remove_connection, - :add_group, - :update_group, - :remove_group, - :set_group_membership, - :commit_drag_layout, - :pin_step_output, - :unpin_step_output, - :disable_step, - :enable_step - ] - - field :payload, :map - field :user_id, :binary_id - field :client_seq, :integer - - belongs_to :workflow, Fizz.Workflows.Workflow - - timestamps(updated_at: false) - end - - def changeset(op, attrs) do - op - |> cast(attrs, [:operation_id, :seq, :type, :payload, :user_id, :client_seq, :workflow_id]) - |> validate_required([:operation_id, :type, :payload, :workflow_id]) - |> unique_constraint(:operation_id) - |> unique_constraint([:workflow_id, :seq]) - end -end diff --git a/lib/fizz/collaboration/edit_session/inversion.ex b/lib/fizz/collaboration/edit_session/inversion.ex deleted file mode 100644 index 75be4da..0000000 --- a/lib/fizz/collaboration/edit_session/inversion.ex +++ /dev/null @@ -1,400 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.Inversion do - @moduledoc """ - Computes inverse operations for undo/redo support. - """ - - alias Fizz.Collaboration.EditorState - alias Fizz.Workflows.WorkflowDraft - - @spec compute_inverse(WorkflowDraft.t(), EditorState.t(), map()) :: - {:ok, [map()]} | {:error, term()} - def compute_inverse(%WorkflowDraft{} = draft, %EditorState{} = editor_state, operation) do - type = field(operation, :type) - payload = field(operation, :payload) || %{} - - case type do - :add_step -> - step_id = field(field(payload, :step) || %{}, :id) - {:ok, [%{type: :remove_step, payload: %{step_id: step_id}}]} - - :remove_step -> - step_id = field(payload, :step_id) - - case find_step(draft, step_id) do - nil -> - {:error, :step_not_found} - - step -> - connections = find_step_connections(draft, step_id) - group_id = find_group_for_step(draft, step_id) - - step_payload = - if is_nil(group_id) do - %{step: to_map(step)} - else - %{step: to_map(step), group_id: group_id} - end - - connection_ops = - Enum.map(connections, fn conn -> - %{type: :add_connection, payload: %{connection: to_map(conn)}} - end) - - {:ok, [%{type: :add_step, payload: step_payload} | connection_ops]} - end - - :update_step_config -> - step_id = field(payload, :step_id) - patch = field(payload, :patch) || [] - - case find_step(draft, step_id) do - nil -> - {:error, :step_not_found} - - step -> - inverse_patch = compute_reverse_patch(field(step, :config) || %{}, patch) - - {:ok, - [ - %{ - type: :update_step_config, - payload: %{step_id: step_id, patch: inverse_patch} - } - ]} - end - - :update_step_position -> - step_id = field(payload, :step_id) - - case find_step(draft, step_id) do - nil -> - {:error, :step_not_found} - - step -> - {:ok, - [ - %{ - type: :update_step_position, - payload: %{step_id: step_id, position: field(step, :position)} - } - ]} - end - - :update_step_positions -> - step_positions = field(payload, :step_positions) || %{} - step_ids = Map.keys(step_positions) - missing_ids = Enum.filter(step_ids, &is_nil(find_step(draft, &1))) - - if missing_ids == [] do - {:ok, - [ - %{ - type: :update_step_positions, - payload: %{step_positions: capture_step_positions(draft, step_ids)} - } - ]} - else - {:error, {:steps_not_found, missing_ids}} - end - - :update_step_metadata -> - step_id = field(payload, :step_id) - changes = field(payload, :changes) || %{} - - case find_step(draft, step_id) do - nil -> - {:error, :step_not_found} - - step -> - previous = - changes - |> Map.keys() - |> Enum.reduce(%{}, fn key, acc -> - Map.put(acc, key, field(step, key)) - end) - - {:ok, - [ - %{ - type: :update_step_metadata, - payload: %{step_id: step_id, changes: previous} - } - ]} - end - - :add_connection -> - conn_id = field(field(payload, :connection) || %{}, :id) - {:ok, [%{type: :remove_connection, payload: %{connection_id: conn_id}}]} - - :remove_connection -> - conn_id = field(payload, :connection_id) - - case find_connection(draft, conn_id) do - nil -> {:error, :connection_not_found} - conn -> {:ok, [%{type: :add_connection, payload: %{connection: to_map(conn)}}]} - end - - :add_group -> - group_id = field(field(payload, :group) || %{}, :id) - {:ok, [%{type: :remove_group, payload: %{group_id: group_id}}]} - - :update_group -> - group_id = field(payload, :group_id) - changes = field(payload, :changes) || %{} - - case find_group(draft, group_id) do - nil -> - {:error, :group_not_found} - - group -> - previous = - changes - |> Map.keys() - |> Enum.reduce(%{}, fn key, acc -> - Map.put(acc, key, field(group, key)) - end) - - {:ok, - [ - %{ - type: :update_group, - payload: %{group_id: group_id, changes: previous} - } - ]} - end - - :remove_group -> - group_id = field(payload, :group_id) - - case find_group(draft, group_id) do - nil -> - {:error, :group_not_found} - - group -> - step_ids = List.wrap(field(group, :step_ids) || []) - step_positions = capture_step_positions(draft, step_ids) - - {:ok, - [ - %{ - type: :add_group, - payload: %{group: to_map(group), step_positions: step_positions} - } - ]} - end - - :set_group_membership -> - step_ids = List.wrap(field(payload, :step_ids) || []) - - inverse_ops = - step_ids - |> Enum.group_by(&find_group_for_step(draft, &1)) - |> Enum.map(fn {group_id, ids} -> - %{ - type: :set_group_membership, - payload: %{ - group_id: group_id, - step_ids: ids, - step_positions: capture_step_positions(draft, ids) - } - } - end) - - {:ok, inverse_ops} - - :commit_drag_layout -> - groups = List.wrap(field(payload, :groups) || []) - step_positions = field(payload, :step_positions) || %{} - group_id_by_step_id = field(payload, :group_id_by_step_id) || %{} - - group_ids = - groups - |> Enum.map(&field(&1, :group_id)) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - - inverse_group_changes = - Enum.map(group_ids, fn group_id -> - group = find_group(draft, group_id) - - %{ - group_id: group_id, - position: - case group do - nil -> %{} - current_group -> field(current_group, :position) || %{} - end - } - end) - - inverse_step_positions = - capture_step_positions( - draft, - Map.keys(step_positions) - ) - - inverse_group_membership = - capture_group_membership( - draft, - Map.keys(group_id_by_step_id) - ) - - {:ok, - [ - %{ - type: :commit_drag_layout, - payload: %{ - txn_id: field(payload, :txn_id), - base_seq: field(payload, :base_seq), - groups: inverse_group_changes, - step_positions: inverse_step_positions, - group_id_by_step_id: inverse_group_membership - } - } - ]} - - :pin_step_output -> - step_id = field(payload, :step_id) - {:ok, [%{type: :unpin_step_output, payload: %{step_id: step_id}}]} - - :unpin_step_output -> - step_id = field(payload, :step_id) - pinned_data = Map.get(editor_state.pinned_outputs, step_id) - - {:ok, - [ - %{ - type: :pin_step_output, - payload: %{step_id: step_id, output_data: pinned_data} - } - ]} - - :disable_step -> - step_id = field(payload, :step_id) - {:ok, [%{type: :enable_step, payload: %{step_id: step_id}}]} - - :enable_step -> - step_id = field(payload, :step_id) - mode = Map.get(editor_state.disabled_mode, step_id, :skip) - - {:ok, - [ - %{ - type: :disable_step, - payload: %{step_id: step_id, mode: mode} - } - ]} - - _ -> - {:error, :unknown_operation_type} - end - end - - defp field(map, key) when is_map(map), do: Map.get(map, key) || Map.get(map, to_string(key)) - defp field(_, _), do: nil - - defp to_map(%{__struct__: _} = struct), do: Map.from_struct(struct) - defp to_map(map) when is_map(map), do: map - - defp find_step(%WorkflowDraft{} = draft, step_id) do - Enum.find(draft.steps || [], fn step -> field(step, :id) == step_id end) - end - - defp find_connection(%WorkflowDraft{} = draft, conn_id) do - Enum.find(draft.connections || [], fn conn -> field(conn, :id) == conn_id end) - end - - defp find_group(%WorkflowDraft{} = draft, group_id) do - Enum.find(List.wrap(draft.groups), fn group -> field(group, :id) == group_id end) - end - - defp find_group_for_step(%WorkflowDraft{} = draft, step_id) do - draft.groups - |> List.wrap() - |> Enum.find_value(fn group -> - step_ids = List.wrap(field(group, :step_ids) || []) - if step_id in step_ids, do: field(group, :id), else: nil - end) - end - - defp find_step_connections(%WorkflowDraft{} = draft, step_id) do - Enum.filter(draft.connections || [], fn conn -> - field(conn, :source_step_id) == step_id or field(conn, :target_step_id) == step_id - end) - end - - defp capture_step_positions(%WorkflowDraft{} = draft, step_ids) do - step_set = MapSet.new(step_ids) - - draft.steps - |> List.wrap() - |> Enum.reduce(%{}, fn step, acc -> - step_id = field(step, :id) - - if MapSet.member?(step_set, step_id) do - Map.put(acc, step_id, field(step, :position) || %{}) - else - acc - end - end) - end - - defp capture_group_membership(%WorkflowDraft{} = draft, step_ids) do - Enum.reduce(step_ids, %{}, fn step_id, acc -> - Map.put(acc, step_id, find_group_for_step(draft, step_id)) - end) - end - - defp compute_reverse_patch(config, patches) do - patches - |> List.wrap() - |> Enum.reverse() - |> Enum.map(&reverse_patch(config, &1)) - |> Enum.reject(&is_nil/1) - end - - defp reverse_patch(config, patch) when is_map(patch) do - op = Map.get(patch, "op") || Map.get(patch, :op) - path = Map.get(patch, "path") || Map.get(patch, :path) - - case op do - "replace" -> - case get_at_path(config, parse_path(path)) do - :missing -> %{"op" => "remove", "path" => path} - value -> %{"op" => "replace", "path" => path, "value" => value} - end - - "add" -> - case get_at_path(config, parse_path(path)) do - :missing -> %{"op" => "remove", "path" => path} - value -> %{"op" => "replace", "path" => path, "value" => value} - end - - "remove" -> - case get_at_path(config, parse_path(path)) do - :missing -> nil - value -> %{"op" => "add", "path" => path, "value" => value} - end - - _ -> - nil - end - end - - defp parse_path(nil), do: [] - defp parse_path("/" <> path), do: String.split(path, "/", trim: true) - defp parse_path(path), do: String.split(path, "/", trim: true) - - defp get_at_path(map, []), do: map - - defp get_at_path(map, [key | rest]) when is_map(map) do - value = Map.get(map, key) || Map.get(map, to_string(key)) - - case value do - nil -> :missing - _ -> get_at_path(value, rest) - end - end - - defp get_at_path(_, _), do: :missing -end diff --git a/lib/fizz/collaboration/edit_session/operations.ex b/lib/fizz/collaboration/edit_session/operations.ex deleted file mode 100644 index 542c550..0000000 --- a/lib/fizz/collaboration/edit_session/operations.ex +++ /dev/null @@ -1,1170 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.Operations do - @moduledoc """ - Pure functions for validating and applying edit operations to workflow drafts. - """ - - alias Fizz.Workflows.WorkflowDraft - alias Fizz.Workflows.Embeds.{Step, Connection, NodeGroup} - alias Fizz.Graph - alias Fizz.Steps.Registry, as: StepRegistry - require Logger - - @default_node_width 150 - @default_node_height 50 - @default_group_width 360 - @default_group_height 240 - @group_content_insets %{right: 24, bottom: 52} - @default_group_font_size 14 - @min_group_font_size 10 - @max_group_font_size 32 - - @doc """ - Normalizes an operation payload by ensuring keys are atoms or strings consistently. - """ - def normalize_payload(nil), do: %{} - - def normalize_payload(payload) when is_map(payload) do - # For now we just return as is, but helpers like `field/2` will handle it. - payload - end - - defp field(map, key) when is_map(map), do: Map.get(map, key) || Map.get(map, to_string(key)) - defp field(_, _), do: nil - - @type operation :: %{ - type: atom(), - payload: map(), - id: String.t(), - user_id: String.t() - } - - @doc "Validate an operation against current draft state." - @spec validate(WorkflowDraft.t(), operation()) :: :ok | {:error, term()} - def validate(draft, operation) do - case operation.type do - :add_step -> - validate_add_step(draft, operation.payload) - - :remove_step -> - validate_remove_step(draft, operation.payload) - - :update_step_config -> - validate_update_step(draft, operation.payload) - - :update_step_position -> - validate_update_step(draft, operation.payload) - - :update_step_positions -> - validate_update_step_positions(draft, operation.payload) - - :update_step_metadata -> - validate_update_step(draft, operation.payload) - - :add_connection -> - validate_add_connection(draft, operation.payload) - - :remove_connection -> - validate_remove_connection(draft, operation.payload) - - :add_group -> - validate_add_group(draft, operation.payload) - - :update_group -> - validate_update_group(draft, operation.payload) - - :remove_group -> - validate_remove_group(draft, operation.payload) - - :set_group_membership -> - validate_set_group_membership(draft, operation.payload) - - :commit_drag_layout -> - validate_commit_drag_layout(draft, operation.payload) - - # Editor operations don't need draft validation - type when type in [:pin_step_output, :unpin_step_output, :disable_step, :enable_step] -> - :ok - - _ -> - {:error, :unknown_operation_type} - end - end - - @doc "Apply an operation to a draft, returning updated draft." - @spec apply(WorkflowDraft.t(), operation() | map()) :: - {:ok, WorkflowDraft.t()} | {:error, term()} - def apply(draft, operation) do - type = field(operation, :type) - payload = field(operation, :payload) - Logger.info("Operations.apply: type=#{inspect(type)} payload=#{inspect(payload)}") - - case do_apply(draft, type, payload) do - {:ok, updated_draft} = result -> - if draft == updated_draft and - type not in [:pin_step_output, :unpin_step_output, :disable_step, :enable_step] do - Logger.warning("Operations.apply: No changes made to draft for type=#{inspect(type)}") - end - - result - - {:error, reason} = error -> - Logger.error("Operations.apply FAILED: type=#{inspect(type)} reason=#{inspect(reason)}") - error - end - end - - # ============================================================================ - # Validation Functions - # ============================================================================ - - defp validate_add_step(draft, payload) do - step_data = field(payload, :step) - step_id = field(step_data, :id) - group_id = field(payload, :group_id) - - cond do - step_exists?(draft, step_id) -> - {:error, {:step_already_exists, step_id}} - - not valid_step_type?(step_data) -> - {:error, :invalid_step_type} - - not is_nil(group_id) and not group_exists?(draft, group_id) -> - {:error, {:group_not_found, group_id}} - - true -> - :ok - end - end - - defp validate_remove_step(draft, payload) do - step_id = field(payload, :step_id) - - if step_exists?(draft, step_id) do - :ok - else - {:error, {:step_not_found, step_id}} - end - end - - defp validate_update_step(draft, payload) do - step_id = field(payload, :step_id) - - if step_exists?(draft, step_id) do - :ok - else - {:error, {:step_not_found, step_id}} - end - end - - defp validate_update_step_positions(draft, payload) do - step_positions = field(payload, :step_positions) - - if is_map(step_positions) do - step_ids = Map.keys(step_positions) - missing = Enum.reject(step_ids, &step_exists?(draft, &1)) - - if missing == [] do - :ok - else - {:error, {:steps_not_found, missing}} - end - else - {:error, :invalid_step_positions} - end - end - - defp validate_add_connection(draft, payload) do - conn_data = field(payload, :connection) - source_id = field(conn_data, :source_step_id) - target_id = field(conn_data, :target_step_id) - conn_id = field(conn_data, :id) - - cond do - connection_exists?(draft, conn_id) -> - {:error, {:connection_already_exists, conn_id}} - - not step_exists?(draft, source_id) -> - {:error, {:source_step_not_found, source_id}} - - not step_exists?(draft, target_id) -> - {:error, {:target_step_not_found, target_id}} - - source_id == target_id -> - {:error, :self_loop_not_allowed} - - would_create_cycle?(draft, source_id, target_id) -> - {:error, :would_create_cycle} - - true -> - validate_subnode_slot_connection(draft, conn_data) - end - end - - defp validate_remove_connection(draft, payload) do - conn_id = field(payload, :connection_id) - - if connection_exists?(draft, conn_id) do - :ok - else - {:error, {:connection_not_found, conn_id}} - end - end - - defp validate_add_group(draft, payload) do - group_data = field(payload, :group) || %{} - group_id = field(group_data, :id) - step_ids = List.wrap(field(group_data, :step_ids) || []) - missing_steps = Enum.reject(step_ids, &step_exists?(draft, &1)) - font_size = field(group_data, :font_size) - - cond do - is_nil(group_id) -> - {:error, :group_id_required} - - group_exists?(draft, group_id) -> - {:error, {:group_already_exists, group_id}} - - step_ids == [] -> - {:error, :group_requires_steps} - - missing_steps != [] -> - {:error, {:group_steps_not_found, missing_steps}} - - true -> - validate_group_font_size(font_size) - end - end - - defp validate_update_group(draft, payload) do - group_id = field(payload, :group_id) - changes = field(payload, :changes) || %{} - font_size = field(changes, :font_size) - - if group_exists?(draft, group_id) do - case validate_group_output_step_change(draft, group_id, changes) do - :ok -> - validate_group_font_size(font_size) - - {:error, _reason} = error -> - error - end - else - {:error, {:group_not_found, group_id}} - end - end - - defp validate_remove_group(draft, payload) do - group_id = field(payload, :group_id) - - if group_exists?(draft, group_id) do - :ok - else - {:error, {:group_not_found, group_id}} - end - end - - defp validate_set_group_membership(draft, payload) do - group_id = field(payload, :group_id) - step_ids = List.wrap(field(payload, :step_ids) || []) - - cond do - step_ids == [] -> - {:error, :group_membership_missing_steps} - - Enum.any?(step_ids, fn step_id -> not step_exists?(draft, step_id) end) -> - missing = Enum.reject(step_ids, &step_exists?(draft, &1)) - {:error, {:group_steps_not_found, missing}} - - is_nil(group_id) -> - :ok - - group_exists?(draft, group_id) -> - :ok - - true -> - {:error, {:group_not_found, group_id}} - end - end - - defp validate_commit_drag_layout(draft, payload) do - groups = List.wrap(field(payload, :groups) || []) - step_positions = field(payload, :step_positions) || %{} - group_id_by_step_id = field(payload, :group_id_by_step_id) || %{} - - cond do - not is_map(step_positions) -> - {:error, :invalid_step_positions} - - not is_map(group_id_by_step_id) -> - {:error, :invalid_group_membership_map} - - true -> - validate_commit_drag_layout_references(draft, groups, step_positions, group_id_by_step_id) - end - end - - defp validate_commit_drag_layout_references(draft, groups, step_positions, group_id_by_step_id) do - group_ids = - groups - |> Enum.map(&field(&1, :group_id)) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - - missing_groups = - group_ids - |> Enum.reject(&group_exists?(draft, &1)) - |> Kernel.++(missing_group_membership_targets(draft, group_id_by_step_id)) - |> Enum.uniq() - - step_ids = - step_positions - |> Map.keys() - |> Kernel.++(Map.keys(group_id_by_step_id)) - |> Enum.uniq() - - missing_steps = Enum.reject(step_ids, &step_exists?(draft, &1)) - - cond do - missing_groups != [] -> - {:error, {:groups_not_found, missing_groups}} - - missing_steps != [] -> - {:error, {:steps_not_found, missing_steps}} - - true -> - :ok - end - end - - defp missing_group_membership_targets(draft, group_id_by_step_id) do - group_id_by_step_id - |> Enum.reduce([], fn {_step_id, group_id}, acc -> - normalized_group_id = - case group_id do - "" -> nil - value -> value - end - - if is_nil(normalized_group_id) or group_exists?(draft, normalized_group_id) do - acc - else - [normalized_group_id | acc] - end - end) - end - - # ============================================================================ - # Apply Functions - # ============================================================================ - - defp do_apply(draft, :add_step, payload) do - step_data = field(payload, :step) - step = build_step(step_data) - new_steps = (draft.steps || []) ++ [step] - group_id = field(payload, :group_id) - step_size = field(payload, :step_size) || %{} - - draft = %{draft | steps: new_steps} - - draft = - if is_nil(group_id) do - draft - else - draft - |> update_groups(fn groups -> add_steps_to_group(groups, group_id, [step.id]) end) - |> maybe_expand_group_for_step(group_id, step, step_size) - end - - {:ok, draft} - end - - defp do_apply(draft, :remove_step, payload) do - step_id = field(payload, :step_id) - Logger.info("Operations.do_apply(:remove_step): step_id=#{inspect(step_id)}") - - # Remove step and all its connections - new_steps = - Enum.reject(draft.steps || [], fn step -> - id = field(step, :id) - match = id == step_id - if match, do: Logger.info("Operations.do_apply: Found step to remove: #{id}") - match - end) - - new_connections = - Enum.reject(draft.connections || [], fn conn -> - source_id = field(conn, :source_step_id) - target_id = field(conn, :target_step_id) - match = source_id == step_id or target_id == step_id - - if match, - do: - Logger.info( - "Operations.do_apply: Removing connection #{field(conn, :id)} because it links to removed step #{step_id}" - ) - - match - end) - - new_groups = - draft.groups - |> List.wrap() - |> remove_steps_from_groups([step_id]) - - {:ok, %{draft | steps: new_steps, connections: new_connections, groups: new_groups}} - end - - defp do_apply(draft, :update_step_config, payload) do - step_id = field(payload, :step_id) - patch = field(payload, :patch) - - update_step(draft, step_id, fn step -> - new_config = apply_json_patch(step.config || %{}, patch) - %{step | config: new_config} - end) - end - - defp do_apply(draft, :update_step_position, payload) do - step_id = field(payload, :step_id) - position = field(payload, :position) - - update_step(draft, step_id, fn step -> - %{step | position: position} - end) - end - - defp do_apply(draft, :update_step_positions, payload) do - step_positions = field(payload, :step_positions) || %{} - {:ok, update_step_positions(draft, step_positions)} - end - - defp do_apply(draft, :update_step_metadata, payload) do - step_id = field(payload, :step_id) - changes = field(payload, :changes) - - # Handle name uniqueness if it's changing - changes = - case Map.get(changes, "name") || Map.get(changes, :name) do - nil -> - changes - - new_name -> - other_steps = Enum.reject(draft.steps || [], &(&1.id == step_id)) - - {unique_name, _unique_step_id} = - Fizz.Workflows.generate_unique_step_identity(other_steps, new_name) - - Map.put(changes, :name, unique_name) - end - - update_step(draft, step_id, fn step -> - step - |> maybe_update(:name, changes) - |> maybe_update(:notes, changes) - |> maybe_update(:config, changes) - end) - end - - defp do_apply(draft, :add_connection, payload) do - conn_data = field(payload, :connection) - connection = build_connection(conn_data) - new_connections = (draft.connections || []) ++ [connection] - {:ok, %{draft | connections: new_connections}} - end - - defp do_apply(draft, :remove_connection, payload) do - conn_id = field(payload, :connection_id) - new_connections = Enum.reject(draft.connections || [], &(field(&1, :id) == conn_id)) - {:ok, %{draft | connections: new_connections}} - end - - defp do_apply(draft, :add_group, payload) do - group_data = field(payload, :group) || %{} - step_positions = field(payload, :step_positions) || %{} - step_ids = List.wrap(field(group_data, :step_ids) || []) - group = build_group(group_data, draft) - - groups = - draft.groups - |> List.wrap() - |> remove_steps_from_groups(step_ids) - |> Kernel.++([group]) - |> normalize_groups() - - draft = %{draft | groups: groups} - draft = update_step_positions(draft, step_positions) - {:ok, draft} - end - - defp do_apply(draft, :update_group, payload) do - group_id = field(payload, :group_id) - changes = field(payload, :changes) || %{} - - update_group(draft, group_id, fn group -> - group - |> maybe_update(:name, changes) - |> maybe_update(:position, changes) - |> maybe_update(:color, changes) - |> maybe_update(:font_size, changes) - |> maybe_update(:collapsed, changes) - |> maybe_update(:output_step_id, changes) - end) - end - - defp do_apply(draft, :remove_group, payload) do - group_id = field(payload, :group_id) - - case find_group(draft, group_id) do - nil -> - {:error, {:group_not_found, group_id}} - - group -> - group_position = field(group, :position) || %{} - offset_x = field(group_position, :x) || 0 - offset_y = field(group_position, :y) || 0 - - step_positions = - group - |> field(:step_ids) - |> List.wrap() - |> Enum.reduce(%{}, fn step_id, acc -> - case find_step(draft, step_id) do - nil -> - acc - - step -> - position = field(step, :position) || %{} - - new_position = %{ - x: (field(position, :x) || 0) + offset_x, - y: (field(position, :y) || 0) + offset_y - } - - Map.put(acc, step_id, new_position) - end - end) - - groups = - draft.groups - |> List.wrap() - |> Enum.reject(fn group_item -> field(group_item, :id) == group_id end) - |> normalize_groups() - - draft = %{draft | groups: groups} - draft = update_step_positions(draft, step_positions) - {:ok, draft} - end - end - - defp do_apply(draft, :set_group_membership, payload) do - group_id = field(payload, :group_id) - step_ids = List.wrap(field(payload, :step_ids) || []) - step_positions = field(payload, :step_positions) || %{} - - groups = - draft.groups - |> List.wrap() - |> remove_steps_from_groups(step_ids) - |> maybe_add_steps_to_group(group_id, step_ids) - |> normalize_groups() - - draft = %{draft | groups: groups} - draft = update_step_positions(draft, step_positions) - {:ok, draft} - end - - defp do_apply(draft, :commit_drag_layout, payload) do - groups = List.wrap(field(payload, :groups) || []) - step_positions = field(payload, :step_positions) || %{} - group_id_by_step_id = field(payload, :group_id_by_step_id) || %{} - membership_step_ids = Map.keys(group_id_by_step_id) - - groups_after_membership = - draft.groups - |> List.wrap() - |> remove_steps_from_groups(membership_step_ids) - |> apply_group_membership_map(group_id_by_step_id) - |> normalize_groups() - - groups_with_positions = apply_group_positions(groups_after_membership, groups) - draft = %{draft | groups: groups_with_positions} - draft = update_step_positions(draft, step_positions) - {:ok, draft} - end - - defp do_apply(draft, type, _payload) - when type in [:pin_step_output, :unpin_step_output, :disable_step, :enable_step] do - {:ok, draft} - end - - defp do_apply(_draft, type, _payload) do - {:error, {:unhandled_operation, type}} - end - - # ============================================================================ - # Helpers - # ============================================================================ - - defp step_exists?(draft, step_id) do - Enum.any?(draft.steps || [], fn step -> field(step, :id) == step_id end) - end - - defp connection_exists?(draft, conn_id) do - Enum.any?(draft.connections || [], fn conn -> field(conn, :id) == conn_id end) - end - - defp group_exists?(draft, group_id) do - Enum.any?(draft.groups || [], fn group -> field(group, :id) == group_id end) - end - - defp find_group(draft, group_id) do - Enum.find(draft.groups || [], fn group -> field(group, :id) == group_id end) - end - - defp find_step(draft, step_id) do - Enum.find(draft.steps || [], fn step -> field(step, :id) == step_id end) - end - - defp update_group(draft, group_id, update_fn) do - groups = draft.groups || [] - - {updated_groups, updated?} = - Enum.reduce(groups, {[], false}, fn group, {acc, updated?} -> - if field(group, :id) == group_id do - updated = - case update_fn.(group) do - {:ok, updated_group} -> updated_group - %NodeGroup{} = updated_group -> updated_group - updated_group when is_map(updated_group) -> updated_group - end - - {[updated | acc], true} - else - {[group | acc], updated?} - end - end) - - if updated? do - {:ok, %{draft | groups: Enum.reverse(updated_groups)}} - else - {:error, {:group_not_found, group_id}} - end - end - - defp update_groups(draft, update_fn) do - groups = update_fn.(List.wrap(draft.groups)) - %{draft | groups: normalize_groups(groups)} - end - - defp remove_steps_from_groups(groups, step_ids) do - step_id_set = MapSet.new(step_ids) - - groups - |> Enum.map(fn group -> - updated_step_ids = - group - |> field(:step_ids) - |> List.wrap() - |> Enum.reject(&MapSet.member?(step_id_set, &1)) - - update_group_fields(group, %{step_ids: updated_step_ids}) - end) - |> normalize_groups() - end - - defp add_steps_to_group(groups, group_id, step_ids) do - Enum.map(groups, fn group -> - if field(group, :id) == group_id do - updated_step_ids = - group - |> field(:step_ids) - |> List.wrap() - |> Kernel.++(step_ids) - |> Enum.uniq() - - update_group_fields(group, %{step_ids: updated_step_ids}) - else - group - end - end) - end - - defp maybe_add_steps_to_group(groups, nil, _step_ids), do: groups - - defp maybe_add_steps_to_group(groups, group_id, step_ids), - do: add_steps_to_group(groups, group_id, step_ids) - - defp maybe_expand_group_for_step(draft, nil, _step, _step_size), do: draft - - defp maybe_expand_group_for_step(draft, group_id, step, step_size) do - step_position = field(step, :position) || %{} - step_rel_x = numeric_field(step_position, :x, 0) - step_rel_y = numeric_field(step_position, :y, 0) - step_width = max(numeric_field(step_size, :width, @default_node_width), 1) - step_height = max(numeric_field(step_size, :height, @default_node_height), 1) - - update_groups(draft, fn groups -> - Enum.map(groups, fn group -> - if field(group, :id) == group_id do - maybe_expand_group_bounds(group, step_rel_x, step_rel_y, step_width, step_height) - else - group - end - end) - end) - end - - defp maybe_expand_group_bounds(group, step_rel_x, step_rel_y, step_width, step_height) do - position = field(group, :position) || %{} - width = numeric_field(position, :width, @default_group_width) - height = numeric_field(position, :height, @default_group_height) - - content_right = width - @group_content_insets.right - content_bottom = height - @group_content_insets.bottom - step_right = step_rel_x + step_width - step_bottom = step_rel_y + step_height - - required_width = - if step_right > content_right, do: width + (step_right - content_right), else: width - - required_height = - if step_bottom > content_bottom, do: height + (step_bottom - content_bottom), else: height - - next_width = max(required_width, @default_group_width) - next_height = max(required_height, @default_group_height) - - if next_width == width and next_height == height do - group - else - update_group_fields(group, %{ - position: - Map.merge(position, %{ - width: next_width, - height: next_height - }) - }) - end - end - - defp apply_group_membership_map(groups, group_id_by_step_id) do - Enum.reduce(group_id_by_step_id, groups, fn {step_id, group_id}, acc -> - normalized_group_id = - case group_id do - "" -> nil - value -> value - end - - case normalized_group_id do - nil -> acc - value -> add_steps_to_group(acc, value, [step_id]) - end - end) - end - - defp apply_group_positions(groups, group_changes) do - Enum.reduce(group_changes, groups, fn change, acc -> - group_id = field(change, :group_id) - position = field(change, :position) || %{} - - Enum.map(acc, fn group -> - if field(group, :id) == group_id do - update_group_fields(group, %{position: position}) - else - group - end - end) - end) - end - - defp normalize_groups(groups) do - groups - |> Enum.reduce([], fn group, acc -> - step_ids = group |> field(:step_ids) |> List.wrap() |> Enum.uniq() - - cond do - step_ids == [] -> - acc - - field(group, :output_step_id) in step_ids -> - [update_group_fields(group, %{step_ids: step_ids}) | acc] - - true -> - [update_group_fields(group, %{step_ids: step_ids, output_step_id: hd(step_ids)}) | acc] - end - end) - |> Enum.reverse() - end - - defp update_step_positions(draft, step_positions) do - if step_positions == %{} do - draft - else - new_steps = - Enum.map(draft.steps || [], fn step -> - case field(step_positions, step.id) do - %{} = position -> - %{step | position: position} - - _ -> - step - end - end) - - %{draft | steps: new_steps} - end - end - - defp build_group(data, draft) when is_map(data) do - step_ids = - data - |> field(:step_ids) - |> List.wrap() - |> Enum.uniq() - - output_step_id = resolve_group_output_step_id(data, step_ids, draft.connections || []) - - %NodeGroup{ - id: field(data, :id), - name: field(data, :name) || "Group", - step_ids: step_ids, - output_step_id: output_step_id, - position: field(data, :position) || %{}, - color: field(data, :color), - font_size: normalize_group_font_size(field(data, :font_size)), - collapsed: field(data, :collapsed) || false - } - end - - defp validate_group_output_step_change(draft, group_id, changes) do - case Map.get(changes, "output_step_id") || Map.get(changes, :output_step_id) do - nil -> - :ok - - output_step_id -> - case find_group(draft, group_id) do - nil -> - {:error, {:group_not_found, group_id}} - - group -> - if output_step_id in (field(group, :step_ids) || []) do - :ok - else - {:error, {:invalid_group_output, output_step_id}} - end - end - end - end - - defp validate_group_font_size(nil), do: :ok - - defp validate_group_font_size(font_size) when is_integer(font_size) do - if font_size in @min_group_font_size..@max_group_font_size do - :ok - else - {:error, {:invalid_group_font_size, font_size}} - end - end - - defp validate_group_font_size(_font_size), do: {:error, :invalid_group_font_size} - - defp normalize_group_font_size(font_size) when is_integer(font_size) do - font_size - |> max(@min_group_font_size) - |> min(@max_group_font_size) - end - - defp normalize_group_font_size(_font_size), do: @default_group_font_size - - defp resolve_group_output_step_id(data, step_ids, connections) do - output_step_id = field(data, :output_step_id) - - cond do - output_step_id in step_ids -> - output_step_id - - step_ids == [] -> - output_step_id - - true -> - infer_group_output_step_id(step_ids, connections) - end - end - - defp infer_group_output_step_id(step_ids, connections) do - step_set = MapSet.new(step_ids) - - outgoing_to_outside = - connections - |> Enum.filter(fn conn -> - source_id = field(conn, :source_step_id) - target_id = field(conn, :target_step_id) - MapSet.member?(step_set, source_id) and not MapSet.member?(step_set, target_id) - end) - |> Enum.map(&field(&1, :source_step_id)) - |> Enum.uniq() - - cond do - length(outgoing_to_outside) == 1 -> - hd(outgoing_to_outside) - - true -> - internal_sources = - connections - |> Enum.filter(fn conn -> - source_id = field(conn, :source_step_id) - target_id = field(conn, :target_step_id) - MapSet.member?(step_set, source_id) and MapSet.member?(step_set, target_id) - end) - |> Enum.map(&field(&1, :source_step_id)) - |> MapSet.new() - - leaf_ids = Enum.reject(step_ids, &MapSet.member?(internal_sources, &1)) - - if length(leaf_ids) == 1 do - hd(leaf_ids) - else - hd(step_ids) - end - end - end - - defp update_group_fields(group, attrs) when is_map(group) do - if Map.has_key?(group, :__struct__) do - struct(group, attrs) - else - Map.merge(group, attrs) - end - end - - defp valid_step_type?(step_data) do - type_id = field(step_data, :type_id) - StepRegistry.exists?(type_id) - end - - defp validate_subnode_slot_connection(draft, conn_data) do - target_input = normalize_target_input(field(conn_data, :target_input)) - - if target_input == "main" do - :ok - else - with {:ok, target_step} <- fetch_step(draft, field(conn_data, :target_step_id)), - {:ok, source_step} <- fetch_step(draft, field(conn_data, :source_step_id)), - {:ok, target_type} <- StepRegistry.get(target_step.type_id), - {:ok, slot_def} <- fetch_slot_definition(target_type, target_input), - :ok <- validate_slot_accepts_type(slot_def, source_step.type_id), - :ok <- validate_slot_cardinality(draft, conn_data, slot_def) do - :ok - else - {:error, :step_not_found} -> - {:error, :step_not_found} - - {:error, :not_found} -> - {:error, :target_step_type_not_found} - - {:error, {:slot_not_found, slot_id}} -> - {:error, {:invalid_target_input, slot_id}} - - {:error, {:slot_disallows_type, slot_id, source_type_id}} -> - {:error, {:slot_disallows_source_type, slot_id, source_type_id}} - - {:error, {:slot_cardinality_exceeded, slot_id}} -> - {:error, {:slot_cardinality_exceeded, slot_id}} - end - end - end - - defp validate_slot_cardinality(draft, conn_data, slot_def) do - case slot_field(slot_def, :cardinality) do - "many" -> - :ok - - _ -> - target_step_id = field(conn_data, :target_step_id) - slot_id = normalize_target_input(field(conn_data, :target_input)) - - existing_count = - draft.connections - |> List.wrap() - |> Enum.count(fn conn -> - field(conn, :target_step_id) == target_step_id and - normalize_target_input(field(conn, :target_input)) == slot_id - end) - - if existing_count >= 1 do - {:error, {:slot_cardinality_exceeded, slot_id}} - else - :ok - end - end - end - - defp fetch_step(draft, step_id) do - case find_step(draft, step_id) do - nil -> {:error, :step_not_found} - step -> {:ok, step} - end - end - - defp fetch_slot_definition(target_type, slot_id) do - slots = Map.get(target_type, :subnode_slots, []) - - case Enum.find(slots, fn slot -> slot_field(slot, :id) == slot_id end) do - nil -> {:error, {:slot_not_found, slot_id}} - slot -> {:ok, slot} - end - end - - defp validate_slot_accepts_type(slot_def, source_type_id) do - accepted_type_ids = - slot_def - |> slot_field(:accepts) - |> case do - accepts when is_map(accepts) -> - Map.get(accepts, "type_ids") || Map.get(accepts, :type_ids) || [] - - _ -> - [] - end - - if source_type_id in accepted_type_ids do - :ok - else - {:error, {:slot_disallows_type, slot_field(slot_def, :id), source_type_id}} - end - end - - defp slot_field(slot, key) when is_map(slot) and is_atom(key) do - Map.get(slot, key) || Map.get(slot, Atom.to_string(key)) - end - - defp numeric_field(map, key, default) when is_map(map) do - case field(map, key) do - value when is_integer(value) or is_float(value) -> - value - - value when is_binary(value) -> - case Float.parse(value) do - {parsed, _rest} -> parsed - :error -> default - end - - _ -> - default - end - end - - defp normalize_target_input(target_input) when target_input in [nil, "", :main, "main"], - do: "main" - - defp normalize_target_input(target_input) when is_binary(target_input), do: target_input - - defp normalize_target_input(target_input) when is_atom(target_input), - do: Atom.to_string(target_input) - - defp normalize_target_input(_target_input), do: "main" - - defp would_create_cycle?(draft, source_id, target_id) do - # Build graph with proposed edge and check for cycles - case Graph.from_workflow(draft.steps, draft.connections) do - {:ok, graph} -> - # Add the proposed edge - test_graph = Graph.add_edge(graph, source_id, target_id) - # Check if target can reach source (would indicate cycle) - target_id in Graph.upstream(test_graph, source_id) - - {:error, _} -> - false - end - end - - defp update_step(draft, step_id, update_fn) do - new_steps = - Enum.map(draft.steps || [], fn step -> - if field(step, :id) == step_id do - case update_fn.(step) do - {:ok, updated} -> updated - %Step{} = updated -> updated - updated when is_map(updated) -> updated - end - else - step - end - end) - - {:ok, %{draft | steps: new_steps}} - end - - defp build_step(data) when is_map(data) do - %Step{ - id: field(data, :id), - type_id: field(data, :type_id), - name: field(data, :name), - config: field(data, :config) || %{}, - position: field(data, :position) || %{x: 0, y: 0}, - notes: field(data, :notes) - } - end - - defp build_connection(data) when is_map(data) do - %Connection{ - id: field(data, :id), - source_step_id: field(data, :source_step_id), - source_output: field(data, :source_output) || "main", - target_step_id: field(data, :target_step_id), - target_input: field(data, :target_input) || "main" - } - end - - @doc false - def apply_json_patch(config, patches) when is_list(patches) do - Enum.reduce(patches, config, &apply_single_patch/2) - end - - defp apply_single_patch(%{"op" => "replace", "path" => path, "value" => value}, config) do - put_at_path(config, parse_path(path), value) - end - - defp apply_single_patch(%{"op" => "add", "path" => path, "value" => value}, config) do - put_at_path(config, parse_path(path), value) - end - - defp apply_single_patch(%{"op" => "remove", "path" => path}, config) do - remove_at_path(config, parse_path(path)) - end - - defp apply_single_patch(_, config), do: config - - defp parse_path("/" <> path), do: String.split(path, "/", trim: true) - defp parse_path(path), do: String.split(path, "/", trim: true) - - defp put_at_path(map, [key], value) do - Map.put(map, key, value) - end - - defp put_at_path(map, [key | rest], value) do - nested = Map.get(map, key, %{}) - Map.put(map, key, put_at_path(nested, rest, value)) - end - - defp remove_at_path(map, [key]) do - Map.delete(map, key) - end - - defp remove_at_path(map, [key | rest]) do - case Map.get(map, key) do - nested when is_map(nested) -> - Map.put(map, key, remove_at_path(nested, rest)) - - _ -> - map - end - end - - defp maybe_update(struct, field, changes) do - case Map.get(changes, field) || Map.get(changes, to_string(field)) do - nil -> struct - value -> Map.put(struct, field, value) - end - end -end diff --git a/lib/fizz/collaboration/edit_session/persistence.ex b/lib/fizz/collaboration/edit_session/persistence.ex deleted file mode 100644 index ba9fcc0..0000000 --- a/lib/fizz/collaboration/edit_session/persistence.ex +++ /dev/null @@ -1,137 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.Persistence do - @moduledoc """ - Handles persistence of edit session state to database. - - Persistence strategy: - - - Operations are buffered in memory - - Batch-persisted every N seconds or N operations - - Draft is updated atomically with operation batch - - Snapshots are taken periodically for faster recovery - """ - - require Logger - - alias Fizz.Repo - alias Fizz.Workflows.WorkflowDraft - alias Fizz.Collaboration.EditOperation - alias Fizz.Collaboration.EditorState - - import Ecto.Query - - @doc "Load pending operations since the last persisted sequence number." - @spec load_pending_ops(String.t(), integer()) :: {:ok, [EditOperation.t()]} | {:error, term()} - def load_pending_ops(workflow_id, last_seq) when is_integer(last_seq) and last_seq >= 0 do - ops = - EditOperation - |> where([o], o.workflow_id == ^workflow_id and o.seq > ^last_seq) - |> order_by([o], asc: o.seq) - |> Repo.all() - - {:ok, ops} - end - - @doc "Persist buffered operations and update draft." - @spec persist(map()) :: {:ok, WorkflowDraft.t()} | {:error, term()} - def persist(%{workflow_id: _workflow_id, draft: draft, op_buffer: ops, seq: seq} = state) do - try do - Repo.transaction(fn -> - # 1. Batch insert any new operations. - # DB constraints + on_conflict: :nothing handles duplicates efficiently. - if ops != [] do - entries = Enum.map(ops, &operation_to_entry/1) - Repo.insert_all(EditOperation, entries, on_conflict: :nothing) - end - - # 2. Update (or create) the draft with current state. - # Use a fresh DB copy so Ecto can detect changes even when the in-memory - # draft already reflects the latest edits. - editor_state_payload = - case Map.get(state, :editor_state) do - %EditorState{} = editor_state -> EditorState.to_storage(editor_state) - _ -> nil - end - - settings = - draft.settings - |> Kernel.||(%{}) - |> Map.put("last_persisted_seq", seq) - - draft_attrs = %{ - steps: Enum.map(draft.steps || [], &ensure_map/1), - connections: Enum.map(draft.connections || [], &ensure_map/1), - groups: Enum.map(draft.groups || [], &ensure_map/1), - settings: settings - } - - case Repo.get_by(WorkflowDraft, workflow_id: draft.workflow_id) do - nil -> - %WorkflowDraft{workflow_id: draft.workflow_id} - |> WorkflowDraft.changeset(Map.put(draft_attrs, :workflow_id, draft.workflow_id)) - |> maybe_put_editor_state(editor_state_payload) - |> Repo.insert!() - - db_draft -> - db_draft - |> WorkflowDraft.changeset(draft_attrs) - |> maybe_put_editor_state(editor_state_payload) - |> Repo.update!() - end - end) - |> case do - {:ok, persisted_draft} -> - Logger.info("Persistence.persist: Successfully persisted ops and draft.") - {:ok, persisted_draft} - - {:error, reason} -> - Logger.error("Persistence.persist: Transaction failed: #{inspect(reason)}") - {:error, reason} - end - rescue - e -> - Logger.error( - "Persistence.persist: CRASHED: #{inspect(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}" - ) - - reraise e, __STACKTRACE__ - end - end - - @doc "Take a snapshot of current state for faster recovery." - @spec snapshot(String.t(), WorkflowDraft.t(), integer()) :: - {:ok, WorkflowDraft.t()} | {:error, term()} - def snapshot(workflow_id, draft, seq) do - # Could store compressed binary snapshot for very large workflows - # For now, the draft itself serves as the snapshot - persist(%{ - workflow_id: workflow_id, - draft: draft, - # Clear buffer since we're taking snapshot - op_buffer: [], - seq: seq - }) - end - - defp operation_to_entry(op) do - %{ - operation_id: op.operation_id, - seq: op.seq, - type: op.type, - payload: op.payload, - user_id: op.user_id, - client_seq: op.client_seq, - workflow_id: op.workflow_id, - inserted_at: DateTime.utc_now() - } - end - - defp ensure_map(%_{} = struct), do: Map.from_struct(struct) - defp ensure_map(map) when is_map(map), do: map - defp ensure_map(nil), do: nil - - defp maybe_put_editor_state(changeset, nil), do: changeset - - defp maybe_put_editor_state(changeset, editor_state) do - Ecto.Changeset.put_change(changeset, :editor_state, editor_state) - end -end diff --git a/lib/fizz/collaboration/edit_session/presence.ex b/lib/fizz/collaboration/edit_session/presence.ex deleted file mode 100644 index ab845fe..0000000 --- a/lib/fizz/collaboration/edit_session/presence.ex +++ /dev/null @@ -1,249 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.Presence do - @moduledoc """ - Tracks user presence in edit sessions using Phoenix.Presence. - - Manages: - - Who is currently in the session - - Cursor positions - - Step selections - - Step focus (config panel open) - - ## Important Implementation Notes - - Phoenix.Presence broadcasts `presence_diff` events automatically when: - - A process is tracked via `track/4` - - A process updates its metadata via `update/4` - - A tracked process terminates - - Subscribers to the presence topic will receive: - ``` - %Phoenix.Socket.Broadcast{ - topic: "edit_presence:workflow_id", - event: "presence_diff", - payload: %{joins: %{...}, leaves: %{...}} - } - ``` - """ - use Phoenix.Presence, - otp_app: :fizz, - pubsub_server: Fizz.PubSub - - require Logger - - @type cursor :: %{x: number(), y: number()} - - @type presence_meta :: %{ - user: %{id: String.t(), email: String.t(), name: String.t() | nil}, - cursor: cursor() | nil, - dragging_steps: %{optional(String.t()) => %{x: number(), y: number()}} | nil, - dragging_groups: - %{ - optional(String.t()) => %{ - x: number(), - y: number(), - width: number(), - height: number() - } - } - | nil, - selected_steps: [String.t()], - focused_step: String.t() | nil, - joined_at: DateTime.t() - } - - @doc "Topic for a workflow's edit session presence." - def topic(workflow_id), do: "edit_presence:#{workflow_id}" - - @doc """ - Track a user joining an edit session. - - This will broadcast a presence_diff to all subscribers with the new user - in the `joins` payload. - """ - def track_user(workflow_id, user, %Phoenix.LiveView.Socket{}) do - do_track(workflow_id, user, self()) - end - - def track_user(workflow_id, user, %Phoenix.Socket{} = socket) do - do_track_with_socket(workflow_id, user, socket) - end - - def track_user(workflow_id, user, pid) when is_pid(pid) do - do_track(workflow_id, user, pid) - end - - defp do_track(workflow_id, user, pid) do - meta = build_meta(user) - topic = topic(workflow_id) - - Logger.debug("Tracking user #{user.id} on topic #{topic} with pid #{inspect(pid)}") - - case track(pid, topic, user.id, meta) do - {:ok, _ref} = result -> - Logger.debug("Successfully tracked user #{user.id}") - result - - {:error, reason} = error -> - Logger.error("Failed to track user #{user.id}: #{inspect(reason)}") - error - end - end - - defp do_track_with_socket(workflow_id, user, socket) do - meta = build_meta(user) - topic = topic(workflow_id) - - case track(socket, topic, user.id, meta) do - {:ok, _ref} = result -> - result - - {:error, reason} = error -> - Logger.error("Failed to track user #{user.id}: #{inspect(reason)}") - error - end - end - - @doc """ - Update user's cursor position. - - This broadcasts a presence_diff with the updated metadata. - """ - def update_cursor(workflow_id, user_id, %{x: _, y: _} = position) do - update_interaction(workflow_id, user_id, position, nil, nil) - end - - def update_cursor(_workflow_id, _user_id, nil), do: :ok - - @doc """ - Update user's interaction state (cursor and dragging nodes). - """ - def update_interaction(workflow_id, user_id, cursor, dragging_steps, dragging_groups \\ nil) do - topic = topic(workflow_id) - - case update(self(), topic, user_id, fn meta -> - meta = - if is_map(cursor) do - Map.put(meta, :cursor, cursor) - else - meta - end - - meta - |> Map.put(:dragging_steps, dragging_steps) - |> Map.put(:dragging_groups, dragging_groups) - end) do - {:ok, _ref} -> - :ok - - {:error, reason} -> - Logger.warning("Failed to update interaction for user #{user_id}: #{inspect(reason)}") - {:error, reason} - end - end - - @doc """ - Update user's step selection. - - This broadcasts a presence_diff with the updated selection. - """ - def update_selection(workflow_id, user_id, step_ids) when is_list(step_ids) do - topic = topic(workflow_id) - - case update(self(), topic, user_id, fn meta -> - Map.put(meta, :selected_steps, step_ids) - end) do - {:ok, _ref} -> - :ok - - {:error, reason} -> - Logger.warning("Failed to update selection for user #{user_id}: #{inspect(reason)}") - {:error, reason} - end - end - - @doc """ - Update user's focused step (config panel open). - - This broadcasts a presence_diff with the updated focus. - """ - def update_focus(workflow_id, user_id, step_id) do - topic = topic(workflow_id) - - case update(self(), topic, user_id, fn meta -> - Map.put(meta, :focused_step, step_id) - end) do - {:ok, _ref} -> - :ok - - {:error, reason} -> - Logger.warning("Failed to update focus for user #{user_id}: #{inspect(reason)}") - {:error, reason} - end - end - - @doc "Clear user's focus." - def clear_focus(workflow_id, user_id) do - update_focus(workflow_id, user_id, nil) - end - - @doc """ - Untrack a user from the session. - - This broadcasts a presence_diff with the user in the `leaves` payload. - Note: This happens automatically when the tracked process dies. - """ - def untrack_user(workflow_id, user_id) do - untrack(self(), topic(workflow_id), user_id) - end - - @doc "Get all users in a session as a map of user_id => presence data." - def list_users(workflow_id) do - list(topic(workflow_id)) - end - - @doc "Count users in a session." - def count(workflow_id) do - workflow_id - |> topic() - |> list() - |> map_size() - end - - @doc "Get a specific user's presence." - def get_user(workflow_id, user_id) do - workflow_id - |> list_users() - |> Map.get(user_id) - end - - @doc """ - Check if a user is present in a session. - """ - def user_present?(workflow_id, user_id) do - workflow_id - |> list_users() - |> Map.has_key?(user_id) - end - - # ============================================================================= - # Private Helpers - # ============================================================================= - - defp build_meta(user) do - name = Map.get(user, :name) || Map.get(user, "name") || user.email - - %{ - user: %{ - id: user.id, - email: user.email, - name: name - }, - cursor: nil, - dragging_steps: nil, - dragging_groups: nil, - selected_steps: [], - focused_step: nil, - joined_at: DateTime.utc_now() - } - end -end diff --git a/lib/fizz/collaboration/edit_session/pub_sub.ex b/lib/fizz/collaboration/edit_session/pub_sub.ex deleted file mode 100644 index 7d8e7e1..0000000 --- a/lib/fizz/collaboration/edit_session/pub_sub.ex +++ /dev/null @@ -1,121 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.PubSub do - @moduledoc """ - PubSub for collaborative editing sessions. - - All subscriptions require a valid scope with appropriate permissions. - Edit sessions require edit access (not just view access) since they - involve modifying workflow state. - - ## Topics - - - `edit_session:{workflow_id}` - Operations and state changes - - `edit_presence:{workflow_id}` - User presence updates (cursors, selections) - - ## Events - - Operations: - - `{:operation_applied, operation}` - An edit operation was applied - - `{:webhook_test_execution, %{execution_id: execution_id}}` - Test webhook execution created - - Presence: - - `%Phoenix.Socket.Broadcast{event: "presence_diff", ...}` - Phoenix.Presence diff - - `{:lock_acquired, step_id, user_id}` - Step lock acquired - - `{:lock_released, step_id}` - Step lock released - """ - - alias Fizz.Accounts.Scope - - @pubsub Fizz.PubSub - - # Topic builders - - def session_topic(workflow_id), do: "edit_session:#{workflow_id}" - def presence_topic(workflow_id), do: "edit_presence:#{workflow_id}" - - # ============================================================================ - # Authorization - # ============================================================================ - - @doc """ - Checks if the scope can subscribe to edit session updates. - - Edit sessions require edit access (not just view access) since they - involve real-time collaboration on workflow modifications. - - Returns `:ok` if authorized, `{:error, :not_found}` if workflow doesn't exist, - or `{:error, :unauthorized}` if access denied. - """ - @spec authorize_edit(Scope.t() | nil, String.t()) :: - :ok | {:error, :unauthorized | :not_found} - def authorize_edit(nil, _workflow_id), do: {:error, :unauthorized} - - def authorize_edit(%Scope{} = scope, workflow_id) do - case Fizz.Repo.get(Fizz.Workflows.Workflow, workflow_id) do - nil -> - {:error, :not_found} - - workflow -> - if Scope.can_edit_workflow?(scope, workflow) do - :ok - else - {:error, :unauthorized} - end - end - end - - # ============================================================================ - # Broadcasting - # ============================================================================ - - @doc """ - Broadcast an operation to all session subscribers. - """ - @spec broadcast_operation(String.t(), term()) :: :ok - def broadcast_operation(workflow_id, operation) do - Phoenix.PubSub.broadcast(@pubsub, session_topic(workflow_id), {:operation_applied, operation}) - end - - @doc """ - Broadcast a lock acquisition to all session subscribers. - """ - @spec broadcast_lock_acquired(String.t(), String.t(), String.t()) :: :ok - def broadcast_lock_acquired(workflow_id, step_id, user_id) do - Phoenix.PubSub.broadcast( - @pubsub, - session_topic(workflow_id), - {:lock_acquired, step_id, user_id} - ) - end - - @doc """ - Broadcast a lock release to all session subscribers. - """ - @spec broadcast_lock_released(String.t(), String.t()) :: :ok - def broadcast_lock_released(workflow_id, step_id) do - Phoenix.PubSub.broadcast(@pubsub, session_topic(workflow_id), {:lock_released, step_id}) - end - - @doc """ - Broadcast an updated editor state to all session subscribers. - """ - @spec broadcast_editor_state_updated(String.t(), term()) :: :ok - def broadcast_editor_state_updated(workflow_id, editor_state) do - Phoenix.PubSub.broadcast( - @pubsub, - session_topic(workflow_id), - {:editor_state_updated, editor_state} - ) - end - - @doc """ - Broadcast that a test webhook execution was created. - """ - @spec broadcast_webhook_test_execution(String.t(), String.t()) :: :ok - def broadcast_webhook_test_execution(workflow_id, execution_id) do - Phoenix.PubSub.broadcast( - @pubsub, - session_topic(workflow_id), - {:webhook_test_execution, %{execution_id: execution_id}} - ) - end -end diff --git a/lib/fizz/collaboration/edit_session/registry.ex b/lib/fizz/collaboration/edit_session/registry.ex deleted file mode 100644 index 4cc33af..0000000 --- a/lib/fizz/collaboration/edit_session/registry.ex +++ /dev/null @@ -1,6 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.Registry do - @moduledoc """ - Registry for looking up edit sessions by workflow_id. - """ - # This is just a name constant - the actual registry is started in application.ex -end diff --git a/lib/fizz/collaboration/edit_session/server.ex b/lib/fizz/collaboration/edit_session/server.ex deleted file mode 100644 index 5a9c46b..0000000 --- a/lib/fizz/collaboration/edit_session/server.ex +++ /dev/null @@ -1,1262 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.Server do - @moduledoc """ - GenServer managing a single collaborative editing session for a workflow. - - Responsibilities: - - Maintain canonical workflow draft state - - Process and linearize operations from all clients - - Broadcast changes to all participants - - Manage editor state (pins, disabled steps, locks) - - Persist operations and snapshots - """ - use GenServer, restart: :transient - - require Logger - - alias Fizz.Collaboration.{EditorState, EditOperation} - alias Fizz.Collaboration.EditSession.{Inversion, Operations, Persistence, Presence, PubSub} - alias Fizz.Collaboration.EditSession.{UndoEntry, UndoStack} - alias Fizz.Workflows - alias Ecto.UUID - - @idle_timeout :timer.minutes(30) - @persist_interval :timer.seconds(5) - @max_op_buffer 1000 - @webhook_test_timeout :timer.minutes(10) - - defmodule State do - @moduledoc false - defstruct [ - :scope, - :workflow_id, - :draft, - :editor_state, - :seq, - :op_buffer, - :applied_ops, - :undo_stacks, - :dirty, - :persist_timer, - :idle_timer, - :webhook_test_timer - ] - end - - # ============================================================================= - # Client API - # ============================================================================= - - def start_link(opts) do - workflow_id = Keyword.fetch!(opts, :workflow_id) - scope = Keyword.fetch!(opts, :scope) - - GenServer.start_link(__MODULE__, %{workflow_id: workflow_id, scope: scope}, - name: via_tuple(workflow_id) - ) - end - - def via_tuple(workflow_id) do - {:via, Registry, {Fizz.Collaboration.EditSession.Registry, workflow_id}} - end - - @doc "Apply an operation to the workflow." - def apply_operation(workflow_id, operation) do - GenServer.call(via_tuple(workflow_id), {:apply_operation, operation}) - end - - @doc "Undo the last operation group for a user." - def undo(workflow_id, user_id, count \\ 1) do - GenServer.call(via_tuple(workflow_id), {:undo, user_id, count}) - end - - @doc "Redo the last undone operation group for a user." - def redo(workflow_id, user_id, count \\ 1) do - GenServer.call(via_tuple(workflow_id), {:redo, user_id, count}) - end - - @doc "Get undo/redo state for UI." - def get_undo_state(workflow_id, user_id) do - GenServer.call(via_tuple(workflow_id), {:get_undo_state, user_id}) - end - - @doc "Preview draft state after undoing N entries (no mutation)." - def preview_undo(workflow_id, user_id, count \\ 1) do - GenServer.call(via_tuple(workflow_id), {:preview_undo, user_id, count}) - end - - @doc "Get current state for a joining/reconnecting client." - def get_sync_state(workflow_id, client_seq \\ nil) do - GenServer.call(via_tuple(workflow_id), {:get_sync_state, client_seq}) - end - - @doc "Acquire a soft lock on a step for editing." - def acquire_step_lock(workflow_id, step_id, user_id) do - GenServer.call(via_tuple(workflow_id), {:acquire_lock, step_id, user_id}) - end - - @doc "Release a step lock." - def release_step_lock(workflow_id, step_id, user_id) do - GenServer.cast(via_tuple(workflow_id), {:release_lock, step_id, user_id}) - end - - @doc "Get current editor state (pins, disabled, locks)." - def get_editor_state(workflow_id) do - GenServer.call(via_tuple(workflow_id), :get_editor_state) - end - - @doc "Force a persistence of current state." - def persist(workflow_id) do - GenServer.cast(via_tuple(workflow_id), :persist) - end - - @doc "Force a persistence of current state and wait for completion." - def persist_sync(workflow_id) do - GenServer.call(via_tuple(workflow_id), :persist_sync) - end - - @doc "Enable a temporary test webhook listener for a workflow." - def enable_test_webhook(workflow_id, attrs) do - with {:ok, pid} <- lookup_session_pid(workflow_id) do - GenServer.call(pid, {:enable_test_webhook, attrs}) - end - end - - @doc "Disable the active test webhook listener for a workflow." - def disable_test_webhook(workflow_id, step_id \\ nil) do - with {:ok, pid} <- lookup_session_pid(workflow_id) do - GenServer.call(pid, {:disable_test_webhook, step_id}) - end - end - - @doc "Check if a test webhook listener is active for a path and method." - def test_webhook_enabled?(workflow_id, path, method) do - with {:ok, pid} <- lookup_session_pid(workflow_id) do - GenServer.call(pid, {:test_webhook_enabled?, path, method}) - else - _ -> {:error, :not_listening} - end - end - - @doc "Broadcast that a test webhook execution was created for the session." - def notify_webhook_test_execution(workflow_id, execution_id) do - case lookup_session_pid(workflow_id) do - {:ok, pid} -> - GenServer.cast(pid, {:webhook_test_execution, execution_id}) - :ok - - _ -> - :ok - end - end - - # ============================================================================= - # Server Callbacks - # ============================================================================= - - @impl true - def init(%{workflow_id: workflow_id, scope: scope}) do - Logger.metadata(workflow_id: workflow_id, component: :edit_session) - Logger.info("Starting edit session for workflow #{workflow_id}") - - case load_initial_state(workflow_id, scope) do - {:ok, state} -> - persist_timer = Process.send_after(self(), :persist, @persist_interval) - idle_timer = Process.send_after(self(), :idle_timeout, @idle_timeout) - - state = %{ - state - | persist_timer: persist_timer, - idle_timer: idle_timer - } - - {:ok, state} - - {:error, reason} -> - Logger.error("Failed to load initial state: #{inspect(reason)}") - {:stop, reason} - end - end - - @impl true - def handle_call({:apply_operation, operation}, _from, state) do - case process_operation(state, operation, track_undo: true) do - {:ok, new_state, result} -> - new_state = reset_idle_timer(new_state) - {:reply, {:ok, result}, new_state} - - {:error, reason} -> - {:reply, {:error, reason}, state} - end - end - - def handle_call({:undo, user_id, count}, _from, state) do - case apply_undo(state, user_id, count) do - {:ok, new_state, result} -> - new_state = reset_idle_timer(new_state) - {:reply, {:ok, result}, new_state} - - {:error, reason, new_state} -> - {:reply, {:error, reason}, new_state} - end - end - - def handle_call({:redo, user_id, count}, _from, state) do - case apply_redo(state, user_id, count) do - {:ok, new_state, result} -> - new_state = reset_idle_timer(new_state) - {:reply, {:ok, result}, new_state} - - {:error, reason, new_state} -> - {:reply, {:error, reason}, new_state} - end - end - - def handle_call({:get_undo_state, user_id}, _from, state) do - {:reply, {:ok, build_undo_state(state, user_id)}, state} - end - - def handle_call({:preview_undo, user_id, count}, _from, state) do - {:reply, preview_undo_state(state, user_id, count), state} - end - - def handle_call({:get_sync_state, client_seq}, _from, state) do - response = build_sync_response(state, client_seq) - {:reply, {:ok, response}, state} - end - - def handle_call({:acquire_lock, step_id, user_id}, _from, state) do - case EditorState.acquire_lock(state.editor_state, step_id, user_id) do - {:ok, new_editor_state} -> - new_state = %{state | editor_state: new_editor_state} - PubSub.broadcast_lock_acquired(state.workflow_id, step_id, user_id) - {:reply, :ok, new_state} - - {:locked, other_user_id} -> - {:reply, {:error, {:locked_by, other_user_id}}, state} - end - end - - def handle_call(:get_editor_state, _from, state) do - {:reply, {:ok, state.editor_state}, state} - end - - def handle_call({:enable_test_webhook, attrs}, _from, state) do - case resolve_webhook_test(state.draft, attrs) do - {:ok, webhook_test} -> - editor_state = EditorState.enable_webhook_test(state.editor_state, webhook_test) - - state = - state - |> cancel_webhook_test_timer() - |> schedule_webhook_test_timer(webhook_test) - |> Map.put(:editor_state, editor_state) - - broadcast_editor_state_update(state.workflow_id, editor_state) - {:reply, {:ok, webhook_test}, state} - - {:error, reason} -> - {:reply, {:error, reason}, state} - end - end - - def handle_call({:disable_test_webhook, step_id}, _from, state) do - {state, editor_state_changed} = maybe_disable_webhook_test(state, step_id) - - if editor_state_changed do - broadcast_editor_state_update(state.workflow_id, state.editor_state) - end - - {:reply, :ok, state} - end - - def handle_call({:test_webhook_enabled?, path, method}, _from, state) do - case webhook_test_matches?(state.editor_state.webhook_test, path, method) do - {:ok, webhook_test} -> {:reply, {:ok, webhook_test}, state} - :error -> {:reply, {:error, :not_listening}, state} - end - end - - def handle_call(:persist_sync, _from, state) do - {result, new_state} = persist_state(state) - {:reply, result, new_state} - end - - @impl true - def handle_cast({:release_lock, step_id, user_id}, state) do - new_editor_state = EditorState.release_lock(state.editor_state, step_id, user_id) - new_state = %{state | editor_state: new_editor_state} - PubSub.broadcast_lock_released(state.workflow_id, step_id) - {:noreply, new_state} - end - - def handle_cast(:persist, state) do - {_result, new_state} = persist_state(state) - {:noreply, new_state} - end - - def handle_cast({:webhook_test_execution, execution_id}, state) do - PubSub.broadcast_webhook_test_execution(state.workflow_id, execution_id) - {:noreply, state} - end - - @impl true - def handle_info(:persist, state) do - {_result, new_state} = persist_state(state) - persist_timer = Process.send_after(self(), :persist, @persist_interval) - {:noreply, %{new_state | persist_timer: persist_timer}} - end - - def handle_info({:webhook_test_timeout, key}, state) do - {state, editor_state_changed} = maybe_disable_webhook_test(state, key) - - if editor_state_changed do - broadcast_editor_state_update(state.workflow_id, state.editor_state) - end - - {:noreply, state} - end - - def handle_info(:idle_timeout, state) do - if Presence.count(state.workflow_id) == 0 do - Logger.info("Edit session idle with no users, shutting down") - Persistence.persist(state) - {:stop, :normal, state} - else - idle_timer = Process.send_after(self(), :idle_timeout, @idle_timeout) - {:noreply, %{state | idle_timer: idle_timer}} - end - end - - @impl true - def terminate(reason, state) do - Logger.info("Edit session terminating", reason: inspect(reason)) - - try do - Persistence.persist(state) - catch - :exit, _ -> :ok - :error, _ -> :ok - end - - :ok - end - - # ============================================================================= - # Private Functions - # ============================================================================= - - defp load_initial_state(workflow_id, scope) do - with {:ok, draft} <- get_or_create_draft(scope, workflow_id), - last_persisted_seq <- (draft.settings || %{})["last_persisted_seq"] || 0, - {:ok, ops} <- Persistence.load_pending_ops(workflow_id, last_persisted_seq) do - editor_state = - EditorState.from_storage( - workflow_id, - Map.get(draft, :editor_state) || %{}, - draft.settings || %{} - ) - - {draft, editor_state, seq} = - replay_operations(draft, editor_state, ops, last_persisted_seq) - - state = %State{ - scope: scope, - workflow_id: workflow_id, - draft: draft, - editor_state: editor_state, - seq: seq, - op_buffer: ops, - applied_ops: MapSet.new(Enum.map(ops, & &1.operation_id)), - undo_stacks: %{}, - dirty: false - } - - {:ok, state} - end - end - - defp get_or_create_draft(scope, workflow_id) do - case Workflows.get_draft(scope, workflow_id) do - {:ok, draft} -> - {:ok, draft} - - {:error, :not_found} -> - create_empty_draft(scope, workflow_id) - end - end - - defp create_empty_draft(scope, workflow_id) do - with {:ok, workflow} <- Workflows.get_workflow(scope, workflow_id), - {:ok, draft} <- - Workflows.update_workflow_draft(scope, workflow, %{ - steps: [], - connections: [], - groups: [], - settings: %{} - }) do - {:ok, draft} - else - {:error, :access_denied} -> - {:error, :not_found} - - {:error, reason} -> - {:error, reason} - end - end - - defp replay_operations(draft, editor_state, ops, initial_seq) do - try do - Enum.reduce(ops, {draft, editor_state, initial_seq}, fn op, {d, state, seq} -> - {new_draft, new_editor_state, _editor_state_changed} = apply_to_state(d, state, op) - {new_draft, new_editor_state, max(seq, op.seq)} - end) - rescue - error -> - Logger.error("Failed to replay operations during recovery: #{inspect(error)}") - - {draft, editor_state, initial_seq} - end - end - - defp process_operation(state, operation, opts) do - track_undo = Keyword.get(opts, :track_undo, false) - - if MapSet.member?(state.applied_ops, operation.id) do - existing_op = Enum.find(state.op_buffer, &(&1.operation_id == operation.id)) - {:ok, state, %{seq: existing_op.seq, status: :duplicate}} - else - with :ok <- Operations.validate(state.draft, operation), - {:ok, inverse_ops} <- maybe_compute_inverse(state, operation, track_undo) do - new_seq = state.seq + 1 - - {new_draft, new_editor_state, editor_state_changed} = - apply_to_state(state.draft, state.editor_state, operation) - - op_record = %EditOperation{ - operation_id: operation.id, - seq: new_seq, - type: operation.type, - payload: operation.payload, - user_id: operation.user_id, - client_seq: Map.get(operation, :client_seq), - workflow_id: state.workflow_id - } - - new_state = - state - |> Map.put(:draft, new_draft) - |> Map.put(:editor_state, new_editor_state) - |> Map.put(:seq, new_seq) - |> Map.put(:op_buffer, append_to_buffer(state.op_buffer, op_record)) - |> Map.put(:applied_ops, MapSet.put(state.applied_ops, operation.id)) - |> Map.put(:dirty, true) - |> maybe_track_undo(operation, inverse_ops, track_undo) - - # Broadcast the operation to all subscribers - Logger.debug( - "Broadcasting operation #{op_record.operation_id} (type: #{op_record.type}) to topic #{PubSub.session_topic(state.workflow_id)}" - ) - - log_layout_operation_summary(op_record) - - PubSub.broadcast_operation(state.workflow_id, op_record) - - # If editor state changed, broadcast that too - if editor_state_changed do - Logger.debug("Broadcasting editor_state_updated for workflow #{state.workflow_id}") - broadcast_editor_state_update(state.workflow_id, new_editor_state) - end - - {:ok, new_state, %{seq: new_seq, status: :applied}} - end - end - end - - defp maybe_compute_inverse(_state, _operation, false), do: {:ok, []} - - defp maybe_compute_inverse(state, operation, true) do - case Inversion.compute_inverse(state.draft, state.editor_state, operation) do - {:ok, inverse_ops} -> {:ok, inverse_ops} - {:error, reason} -> {:error, {:undo_inverse_failed, reason}} - end - end - - defp maybe_track_undo(state, _operation, _inverse_ops, false), do: state - - defp maybe_track_undo(state, operation, inverse_ops, true) do - user_id = operation.user_id - group_id = Map.get(operation, :undo_group_id) - - label = - Map.get(operation, :undo_label) || - infer_undo_label(state.draft, state.editor_state, operation) - - entry = build_undo_entry(operation, inverse_ops, label, group_id) - update_user_undo_stack(state, user_id, entry, group_id) - end - - defp build_undo_entry(operation, inverse_ops, label, group_id) do - %UndoEntry{ - id: UUID.generate(), - group_id: group_id, - label: label, - timestamp: DateTime.utc_now(), - user_id: operation.user_id, - operations: [operation_for_entry(operation)], - inverse_ops: inverse_ops - } - end - - defp update_user_undo_stack(state, user_id, entry, group_id) do - stack = get_user_undo_stack(state, user_id) - - updated_stack = - case {group_id, stack.undo} do - {nil, _} -> - UndoStack.push(stack, entry) - - {^group_id, [%UndoEntry{group_id: ^group_id} = head | rest]} -> - merged = merge_undo_entry(head, entry) - UndoStack.push(%{stack | undo: rest}, merged) - - _ -> - UndoStack.push(stack, entry) - end - - put_user_undo_stack(state, user_id, updated_stack) - end - - defp merge_undo_entry(existing, incoming) do - %UndoEntry{ - existing - | label: existing.label || incoming.label, - timestamp: incoming.timestamp, - operations: existing.operations ++ incoming.operations, - inverse_ops: incoming.inverse_ops ++ existing.inverse_ops - } - end - - defp operation_for_entry(operation) do - %{type: operation.type, payload: operation.payload} - end - - defp get_user_undo_stack(state, user_id) do - Map.get(state.undo_stacks || %{}, user_id, UndoStack.new()) - end - - defp put_user_undo_stack(state, user_id, stack) do - %{state | undo_stacks: Map.put(state.undo_stacks || %{}, user_id, stack)} - end - - defp build_undo_state(state, user_id) do - stack = get_user_undo_stack(state, user_id) - next_undo = List.first(stack.undo) - next_redo = List.first(stack.redo) - undo_stack = build_undo_entries(stack.undo) - redo_stack = build_undo_entries(stack.redo) - - %{ - canUndo: stack.undo != [], - canRedo: stack.redo != [], - undoLabel: if(next_undo, do: next_undo.label, else: nil), - redoLabel: if(next_redo, do: next_redo.label, else: nil), - undoStack: undo_stack, - redoStack: redo_stack - } - end - - defp build_undo_entries(entries) when is_list(entries) do - entries - |> Enum.with_index() - |> Enum.map(fn {entry, index} -> format_undo_entry(entry, index + 1) end) - end - - defp build_undo_entries(_entries), do: [] - - defp format_undo_entry(%UndoEntry{} = entry, depth) do - %{ - id: entry.id, - label: entry.label, - timestamp: format_timestamp(entry.timestamp), - depth: depth - } - end - - defp format_timestamp(%DateTime{} = timestamp), do: DateTime.to_iso8601(timestamp) - defp format_timestamp(_timestamp), do: nil - - defp preview_undo_state(state, user_id, count) do - count = - case count do - value when is_integer(value) and value > 0 -> - value - - value when is_binary(value) -> - case Integer.parse(value) do - {parsed, _} when parsed > 0 -> parsed - _ -> 1 - end - - _ -> - 1 - end - - stack = get_user_undo_stack(state, user_id) - entries = Enum.take(stack.undo, count) - operations = Enum.flat_map(entries, fn entry -> entry.inverse_ops || [] end) - - case simulate_operations(state, operations) do - {:ok, draft, _editor_state} -> {:ok, draft} - {:error, reason} -> {:error, reason} - end - end - - defp apply_undo(state, user_id, count) when count > 0 do - Enum.reduce_while(1..count, {:ok, state, %{label: nil, operation_count: 0}}, fn _, - {:ok, - acc_state, - acc_result} -> - case apply_single_undo(acc_state, user_id) do - {:ok, new_state, result} -> - updated_result = %{ - label: result.label, - operation_count: acc_result.operation_count + result.operation_count - } - - {:cont, {:ok, new_state, updated_result}} - - {:error, reason, new_state} -> - {:halt, {:error, reason, new_state}} - end - end) - end - - defp apply_undo(state, _user_id, _count), do: {:error, :invalid_undo_count, state} - - defp apply_redo(state, user_id, count) when count > 0 do - Enum.reduce_while(1..count, {:ok, state, %{label: nil, operation_count: 0}}, fn _, - {:ok, - acc_state, - acc_result} -> - case apply_single_redo(acc_state, user_id) do - {:ok, new_state, result} -> - updated_result = %{ - label: result.label, - operation_count: acc_result.operation_count + result.operation_count - } - - {:cont, {:ok, new_state, updated_result}} - - {:error, reason, new_state} -> - {:halt, {:error, reason, new_state}} - end - end) - end - - defp apply_redo(state, _user_id, _count), do: {:error, :invalid_redo_count, state} - - defp apply_single_undo(state, user_id) do - case UndoStack.pop_undo(get_user_undo_stack(state, user_id)) do - :empty -> - {:error, :nothing_to_undo, state} - - {:ok, entry, stack_after_pop} -> - case simulate_operations(state, entry.inverse_ops) do - {:ok, _draft, _editor_state} -> - case apply_operations_without_tracking(state, user_id, entry.inverse_ops) do - {:ok, new_state} -> - updated_stack = %{stack_after_pop | redo: [entry | stack_after_pop.redo]} - new_state = put_user_undo_stack(new_state, user_id, updated_stack) - result = %{label: entry.label, operation_count: length(entry.inverse_ops)} - {:ok, new_state, result} - - {:error, reason, new_state} -> - {:error, {:conflict, reason, entry.label}, new_state} - end - - {:error, reason} -> - new_state = put_user_undo_stack(state, user_id, stack_after_pop) - {:error, {:conflict, reason, entry.label}, new_state} - end - end - end - - defp apply_single_redo(state, user_id) do - case UndoStack.pop_redo(get_user_undo_stack(state, user_id)) do - :empty -> - {:error, :nothing_to_redo, state} - - {:ok, entry, stack_after_pop} -> - case simulate_operations(state, entry.operations) do - {:ok, _draft, _editor_state} -> - case apply_operations_without_tracking(state, user_id, entry.operations) do - {:ok, new_state} -> - updated_stack = %{stack_after_pop | undo: [entry | stack_after_pop.undo]} - new_state = put_user_undo_stack(new_state, user_id, updated_stack) - result = %{label: entry.label, operation_count: length(entry.operations)} - {:ok, new_state, result} - - {:error, reason, new_state} -> - {:error, {:conflict, reason, entry.label}, new_state} - end - - {:error, reason} -> - new_state = put_user_undo_stack(state, user_id, stack_after_pop) - {:error, {:conflict, reason, entry.label}, new_state} - end - end - end - - defp simulate_operations(state, operations) do - Enum.reduce_while(operations, {:ok, state.draft, state.editor_state}, fn op, - {:ok, draft, - editor_state} -> - case Operations.validate(draft, op) do - :ok -> - {new_draft, new_editor_state, _changed} = apply_to_state(draft, editor_state, op) - {:cont, {:ok, new_draft, new_editor_state}} - - {:error, reason} -> - {:halt, {:error, reason}} - end - end) - end - - defp apply_operations_without_tracking(state, user_id, operations) do - Enum.reduce_while(operations, {:ok, state}, fn op, {:ok, acc_state} -> - operation = %{ - id: UUID.generate(), - type: op.type, - payload: op.payload, - user_id: user_id, - client_seq: nil - } - - case process_operation(acc_state, operation, track_undo: false) do - {:ok, new_state, _result} -> {:cont, {:ok, new_state}} - {:error, reason} -> {:halt, {:error, reason, acc_state}} - end - end) - end - - defp infer_undo_label(draft, _editor_state, operation) do - type = operation.type - payload = operation.payload || %{} - - case type do - :add_step -> - step = Map.get(payload, :step) || Map.get(payload, "step") || %{} - "Add Step: #{Map.get(step, :name) || Map.get(step, "name") || "Step"}" - - :remove_step -> - step_id = Map.get(payload, :step_id) || Map.get(payload, "step_id") - "Delete Step: #{lookup_step_name(draft, step_id)}" - - :update_step_config -> - step_id = Map.get(payload, :step_id) || Map.get(payload, "step_id") - "Edit Config: #{lookup_step_name(draft, step_id)}" - - :update_step_position -> - step_id = Map.get(payload, :step_id) || Map.get(payload, "step_id") - "Move Step: #{lookup_step_name(draft, step_id)}" - - :update_step_positions -> - step_positions = - Map.get(payload, :step_positions) || Map.get(payload, "step_positions") || %{} - - count = map_size(step_positions) - "Move #{count} Step#{if(count == 1, do: "", else: "s")}" - - :update_step_metadata -> - step_id = Map.get(payload, :step_id) || Map.get(payload, "step_id") - "Update Step: #{lookup_step_name(draft, step_id)}" - - :add_connection -> - "Add Connection" - - :remove_connection -> - "Remove Connection" - - :add_group -> - group = Map.get(payload, :group) || Map.get(payload, "group") || %{} - "Add Group: #{Map.get(group, :name) || Map.get(group, "name") || "Group"}" - - :update_group -> - group_id = Map.get(payload, :group_id) || Map.get(payload, "group_id") - "Update Group: #{lookup_group_name(draft, group_id)}" - - :remove_group -> - group_id = Map.get(payload, :group_id) || Map.get(payload, "group_id") - "Remove Group: #{lookup_group_name(draft, group_id)}" - - :set_group_membership -> - "Update Group Members" - - :commit_drag_layout -> - "Commit Drag Layout" - - :pin_step_output -> - step_id = Map.get(payload, :step_id) || Map.get(payload, "step_id") - "Pin Output: #{lookup_step_name(draft, step_id)}" - - :unpin_step_output -> - step_id = Map.get(payload, :step_id) || Map.get(payload, "step_id") - "Unpin Output: #{lookup_step_name(draft, step_id)}" - - :disable_step -> - step_id = Map.get(payload, :step_id) || Map.get(payload, "step_id") - "Disable Step: #{lookup_step_name(draft, step_id)}" - - :enable_step -> - step_id = Map.get(payload, :step_id) || Map.get(payload, "step_id") - "Enable Step: #{lookup_step_name(draft, step_id)}" - - _ -> - "Undo" - end - end - - defp lookup_step_name(draft, step_id) do - draft.steps - |> List.wrap() - |> Enum.find_value("Step", fn step -> - if Map.get(step, :id) == step_id or Map.get(step, "id") == step_id do - Map.get(step, :name) || Map.get(step, "name") - end - end) - end - - defp lookup_group_name(draft, group_id) do - draft.groups - |> List.wrap() - |> Enum.find_value("Group", fn group -> - if Map.get(group, :id) == group_id or Map.get(group, "id") == group_id do - Map.get(group, :name) || Map.get(group, "name") - end - end) - end - - defp apply_to_state(draft, editor_state, operation) do - case operation.type do - # Workflow structure operations - modify draft - type - when type in [ - :add_step, - :remove_step, - :update_step_config, - :update_step_position, - :update_step_positions, - :update_step_metadata, - :add_connection, - :remove_connection, - :add_group, - :update_group, - :remove_group, - :set_group_membership, - :commit_drag_layout - ] -> - case Operations.apply(draft, operation) do - {:ok, new_draft} -> - # Clean up editor state if step was removed - new_editor_state = - if type == :remove_step do - step_id = - Map.get(operation.payload, :step_id) || Map.get(operation.payload, "step_id") - - editor_state - |> EditorState.unpin_output(step_id) - |> EditorState.enable_step(step_id) - else - editor_state - end - - {new_draft, new_editor_state, type == :remove_step} - - {:error, reason} -> - Logger.error( - "Failed to apply operation #{inspect(operation.type)}: #{inspect(reason)}" - ) - - {draft, editor_state, false} - end - - # Editor-only operations - modify editor state only - :pin_step_output -> - step_id = Map.get(operation.payload, :step_id) || Map.get(operation.payload, "step_id") - - output_data = - cond do - Map.has_key?(operation.payload, :output_data) -> - Map.get(operation.payload, :output_data) - - Map.has_key?(operation.payload, "output_data") -> - Map.get(operation.payload, "output_data") - - true -> - %{} - end - - new_editor_state = - EditorState.pin_output( - editor_state, - step_id, - output_data - ) - - {draft, new_editor_state, true} - - :unpin_step_output -> - step_id = Map.get(operation.payload, :step_id) || Map.get(operation.payload, "step_id") - - new_editor_state = - EditorState.unpin_output( - editor_state, - step_id - ) - - {draft, new_editor_state, true} - - :disable_step -> - step_id = Map.get(operation.payload, :step_id) || Map.get(operation.payload, "step_id") - mode = Map.get(operation.payload, :mode) || Map.get(operation.payload, "mode") || :skip - - new_editor_state = - EditorState.disable_step( - editor_state, - step_id, - mode - ) - - {draft, new_editor_state, true} - - :enable_step -> - new_editor_state = - EditorState.enable_step( - editor_state, - Map.get(operation.payload, :step_id) || Map.get(operation.payload, "step_id") - ) - - {draft, new_editor_state, true} - - _ -> - {draft, editor_state, false} - end - end - - defp lookup_session_pid(workflow_id) do - case Registry.lookup(Fizz.Collaboration.EditSession.Registry, workflow_id) do - [{pid, _}] -> {:ok, pid} - [] -> {:error, :not_found} - end - end - - defp resolve_webhook_test(draft, attrs) do - step_id = Map.get(attrs, :step_id) || Map.get(attrs, "step_id") - path = Map.get(attrs, :path) || Map.get(attrs, "path") - method = Map.get(attrs, :method) || Map.get(attrs, "method") - user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id") - - case find_webhook_step(draft, step_id, path) do - {:ok, step, path_from_step} -> - method = - normalize_method( - Map.get(step.config, "http_method") || Map.get(step.config, :http_method) || method - ) - - {:ok, - %{ - step_id: step.id, - path: path_from_step, - method: method, - enabled_by: user_id - }} - - :error -> - normalized_path = normalize_path(path) - normalized_method = normalize_method(method) - - if webhook_trigger_exists?(draft, normalized_path, normalized_method) do - {:ok, - %{ - step_id: step_id, - path: normalized_path, - method: normalized_method, - enabled_by: user_id - }} - else - {:error, :webhook_not_found} - end - end - end - - defp find_webhook_step(_draft, nil, nil), do: :error - - defp find_webhook_step(draft, step_id, path) do - step = - Enum.find(draft.steps, fn step -> - step.id == step_id && step.type_id == "webhook_trigger" - end) - - cond do - step -> - {:ok, step, - normalize_path(Map.get(step.config, "path") || Map.get(step.config, :path) || step.id)} - - true -> - normalized_path = normalize_path(path) - - step_by_path = - Enum.find(draft.steps, fn step -> - step.type_id == "webhook_trigger" && - normalize_path( - Map.get(step.config, "path") || Map.get(step.config, :path) || step.id - ) == - normalized_path - end) - - if step_by_path do - {:ok, step_by_path, - normalize_path( - Map.get(step_by_path.config, "path") || Map.get(step_by_path.config, :path) || - step_by_path.id - )} - else - :error - end - end - end - - defp webhook_trigger_exists?(_draft, nil, _method), do: false - - defp webhook_trigger_exists?(draft, path, method) do - # Only search steps (triggers are now steps) - Enum.any?(draft.steps || [], fn step -> - step.type_id == "webhook_trigger" && - normalize_path(Map.get(step.config, "path") || Map.get(step.config, :path) || step.id) == - path && - method_matches?( - Map.get(step.config, "http_method") || Map.get(step.config, :http_method), - method - ) - end) - end - - defp webhook_test_matches?(nil, _path, _method), do: :error - - defp webhook_test_matches?(webhook_test, path, method) do - normalized_path = normalize_path(path) - normalized_method = normalize_method(method) - - if webhook_test.path == normalized_path && - method_matches?(webhook_test.method, normalized_method) do - {:ok, webhook_test} - else - :error - end - end - - defp normalize_path(nil), do: nil - - defp normalize_path(path) when is_binary(path) do - path - |> String.trim() - |> String.trim_leading("/") - |> String.trim_trailing("/") - |> case do - "" -> nil - trimmed -> trimmed - end - end - - defp normalize_method(nil), do: "POST" - - defp normalize_method(method) when is_binary(method) do - method - |> String.trim() - |> case do - "" -> "POST" - trimmed -> String.upcase(trimmed) - end - end - - defp method_matches?(configured_method, incoming_method) do - normalized_config = normalize_method(configured_method) - normalized_incoming = normalize_method(incoming_method) - - normalized_config == "ANY" or normalized_config == normalized_incoming - end - - defp schedule_webhook_test_timer(state, webhook_test) do - key = webhook_test_key(webhook_test) - timer = Process.send_after(self(), {:webhook_test_timeout, key}, @webhook_test_timeout) - %{state | webhook_test_timer: timer} - end - - defp cancel_webhook_test_timer(state) do - if state.webhook_test_timer do - Process.cancel_timer(state.webhook_test_timer) - end - - %{state | webhook_test_timer: nil} - end - - defp webhook_test_key(%{step_id: step_id}) when is_binary(step_id), do: {:step, step_id} - defp webhook_test_key(%{path: path}) when is_binary(path), do: {:path, path} - defp webhook_test_key(_), do: :unknown - - defp maybe_disable_webhook_test(state, nil) do - do_disable_webhook_test(state, state.editor_state.webhook_test) - end - - defp maybe_disable_webhook_test(state, {:step, step_id}) do - current = state.editor_state.webhook_test - - if current && current.step_id == step_id do - do_disable_webhook_test(state, current) - else - {state, false} - end - end - - defp maybe_disable_webhook_test(state, {:path, path}) do - current = state.editor_state.webhook_test - - if current && current.path == path do - do_disable_webhook_test(state, current) - else - {state, false} - end - end - - defp maybe_disable_webhook_test(state, step_id) when is_binary(step_id) do - current = state.editor_state.webhook_test - - if current && current.step_id == step_id do - do_disable_webhook_test(state, current) - else - {state, false} - end - end - - defp maybe_disable_webhook_test(state, _step_id), do: {state, false} - - defp do_disable_webhook_test(state, nil), do: {cancel_webhook_test_timer(state), false} - - defp do_disable_webhook_test(state, _current) do - editor_state = EditorState.disable_webhook_test(state.editor_state) - - state = - state - |> cancel_webhook_test_timer() - |> Map.put(:editor_state, editor_state) - - {state, true} - end - - defp broadcast_editor_state_update(workflow_id, editor_state) do - PubSub.broadcast_editor_state_updated(workflow_id, editor_state) - end - - defp append_to_buffer(buffer, op) do - [op | buffer] - |> Enum.take(@max_op_buffer) - end - - defp build_sync_response(state, nil) do - %{ - type: :full_sync, - draft: state.draft, - editor_state: serialize_editor_state(state.editor_state), - seq: state.seq - } - end - - defp build_sync_response(state, client_seq) do - gap = state.seq - client_seq - - cond do - gap == 0 -> - %{type: :up_to_date, seq: state.seq} - - gap > 0 and gap <= length(state.op_buffer) -> - ops = - state.op_buffer - |> Enum.filter(&(&1.seq > client_seq)) - |> Enum.sort_by(& &1.seq) - - %{type: :incremental, ops: ops, seq: state.seq} - - true -> - %{ - type: :full_sync, - draft: state.draft, - editor_state: serialize_editor_state(state.editor_state), - seq: state.seq - } - end - end - - defp serialize_editor_state(editor_state) do - %{ - pinned_outputs: editor_state.pinned_outputs, - disabled_steps: MapSet.to_list(editor_state.disabled_steps), - step_locks: editor_state.step_locks, - webhook_test: editor_state.webhook_test - } - end - - defp reset_idle_timer(state) do - if state.idle_timer, do: Process.cancel_timer(state.idle_timer) - idle_timer = Process.send_after(self(), :idle_timeout, @idle_timeout) - %{state | idle_timer: idle_timer} - end - - defp log_layout_operation_summary(%EditOperation{type: :commit_drag_layout} = operation) do - payload = operation.payload || %{} - groups = payload[:groups] || payload["groups"] || [] - - step_positions = - case payload[:step_positions] || payload["step_positions"] do - value when is_map(value) -> value - _ -> %{} - end - - group_membership = - case payload[:group_id_by_step_id] || payload["group_id_by_step_id"] do - value when is_map(value) -> value - _ -> %{} - end - - txn_id = payload[:txn_id] || payload["txn_id"] - base_seq = payload[:base_seq] || payload["base_seq"] - - Logger.debug( - "commit_drag_layout applied", - operation_id: operation.operation_id, - seq: operation.seq, - txn_id: txn_id, - base_seq: base_seq, - group_count: length(List.wrap(groups)), - step_position_count: map_size(step_positions), - membership_count: map_size(group_membership) - ) - end - - defp log_layout_operation_summary(_operation), do: :ok - - defp persist_state(state) do - if state.dirty do - case Persistence.persist(state) do - {:ok, persisted_draft} -> - Logger.debug("Persisted edit session state") - {:ok, %{state | draft: persisted_draft, dirty: false}} - - {:error, reason} -> - Logger.error("Failed to persist: #{inspect(reason)}") - {{:error, reason}, state} - end - else - {:noop, state} - end - end -end diff --git a/lib/fizz/collaboration/edit_session/supervisor.ex b/lib/fizz/collaboration/edit_session/supervisor.ex deleted file mode 100644 index f5e44c6..0000000 --- a/lib/fizz/collaboration/edit_session/supervisor.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.Supervisor do - @moduledoc """ - DynamicSupervisor for edit session processes. - """ - use DynamicSupervisor - - alias Fizz.Collaboration.EditSession.Server - - def start_link(init_arg) do - DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) - end - - @impl true - def init(_init_arg) do - DynamicSupervisor.init(strategy: :one_for_one) - end - - @doc "Start or get an existing edit session for a workflow." - def ensure_session(scope, workflow_id) do - case Registry.lookup(Fizz.Collaboration.EditSession.Registry, workflow_id) do - [{pid, _}] -> - {:ok, pid} - - [] -> - start_session(scope, workflow_id) - end - end - - @doc "Start a new edit session." - def start_session(scope, workflow_id) do - spec = {Server, workflow_id: workflow_id, scope: scope} - DynamicSupervisor.start_child(__MODULE__, spec) - end - - @doc "Stop an edit session." - def stop_session(workflow_id) do - case Registry.lookup(Fizz.Collaboration.EditSession.Registry, workflow_id) do - [{pid, _}] -> - DynamicSupervisor.terminate_child(__MODULE__, pid) - - [] -> - {:error, :not_found} - end - end -end diff --git a/lib/fizz/collaboration/edit_session/undo_entry.ex b/lib/fizz/collaboration/edit_session/undo_entry.ex deleted file mode 100644 index 9512d18..0000000 --- a/lib/fizz/collaboration/edit_session/undo_entry.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.UndoEntry do - @moduledoc """ - Represents a grouped undo/redo entry for a single user. - """ - - defstruct [ - :id, - :group_id, - :label, - :timestamp, - :user_id, - operations: [], - inverse_ops: [] - ] -end diff --git a/lib/fizz/collaboration/edit_session/undo_stack.ex b/lib/fizz/collaboration/edit_session/undo_stack.ex deleted file mode 100644 index a6d7681..0000000 --- a/lib/fizz/collaboration/edit_session/undo_stack.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.UndoStack do - @moduledoc """ - Per-user undo/redo stack with max size trimming. - """ - - alias Fizz.Collaboration.EditSession.UndoEntry - - defstruct undo: [], redo: [], max_size: 100 - - @spec new(non_neg_integer()) :: %__MODULE__{} - def new(max_size \\ 100) do - %__MODULE__{max_size: max_size} - end - - @spec push(%__MODULE__{}, UndoEntry.t()) :: %__MODULE__{} - def push(%__MODULE__{} = stack, %UndoEntry{} = entry) do - stack - |> Map.put(:undo, [entry | stack.undo]) - |> Map.put(:redo, []) - |> trim() - end - - @spec pop_undo(%__MODULE__{}) :: {:ok, UndoEntry.t(), %__MODULE__{}} | :empty - def pop_undo(%__MODULE__{undo: []}), do: :empty - - def pop_undo(%__MODULE__{} = stack) do - [entry | rest] = stack.undo - {:ok, entry, %{stack | undo: rest}} - end - - @spec pop_redo(%__MODULE__{}) :: {:ok, UndoEntry.t(), %__MODULE__{}} | :empty - def pop_redo(%__MODULE__{redo: []}), do: :empty - - def pop_redo(%__MODULE__{} = stack) do - [entry | rest] = stack.redo - {:ok, entry, %{stack | redo: rest}} - end - - defp trim(%__MODULE__{} = stack) do - if length(stack.undo) > stack.max_size do - %{stack | undo: Enum.take(stack.undo, stack.max_size)} - else - stack - end - end -end diff --git a/lib/fizz/collaboration/editor_state.ex b/lib/fizz/collaboration/editor_state.ex deleted file mode 100644 index 9efd938..0000000 --- a/lib/fizz/collaboration/editor_state.ex +++ /dev/null @@ -1,193 +0,0 @@ -defmodule Fizz.Collaboration.EditorState do - @moduledoc """ - Editor state for a collaborative session. - - This state is persisted into the workflow draft's `editor_state` column so - pins/disabled steps survive session restarts. - """ - - alias Fizz.Runtime.Serializer - - defstruct [ - :workflow_id, - # %{step_id => output_data} - pinned_outputs: %{}, - disabled_steps: MapSet.new(), - disabled_mode: %{}, - # step_id for partial execution - execution_start: nil, - # %{step_id => user_id} - soft locks - step_locks: %{}, - # %{step_id => DateTime} - for timeout - lock_timestamps: %{}, - # %{path: string, method: string, step_id: string, enabled_by: string} - webhook_test: nil - ] - - # 30 seconds - @lock_timeout_ms 30_000 - - def pin_output(state, step_id, output_data) do - %{state | pinned_outputs: Map.put(state.pinned_outputs, step_id, output_data)} - end - - def unpin_output(state, step_id) do - %{state | pinned_outputs: Map.delete(state.pinned_outputs, step_id)} - end - - def disable_step(state, step_id, mode \\ :skip) do - %{ - state - | disabled_steps: MapSet.put(state.disabled_steps, step_id), - disabled_mode: Map.put(state.disabled_mode, step_id, mode) - } - end - - def enable_step(state, step_id) do - %{ - state - | disabled_steps: MapSet.delete(state.disabled_steps, step_id), - disabled_mode: Map.delete(state.disabled_mode, step_id) - } - end - - def enable_webhook_test(state, webhook_test) when is_map(webhook_test) do - %{state | webhook_test: webhook_test} - end - - def disable_webhook_test(state) do - %{state | webhook_test: nil} - end - - def to_storage(%__MODULE__{} = state) do - %{ - "pinned_outputs" => normalize_pinned_outputs(state.pinned_outputs), - "disabled_steps" => MapSet.to_list(state.disabled_steps), - "disabled_mode" => state.disabled_mode - } - end - - @deprecated "Use to_storage/1" - def to_settings(%__MODULE__{} = state), do: to_storage(state) - - def from_storage(workflow_id, editor_state, settings \\ %{}) - - def from_storage(workflow_id, editor_state, settings) - when is_map(editor_state) and is_map(settings) do - source = - if map_size(editor_state) > 0 do - editor_state - else - resolve_editor_state(settings) - end - - pinned_outputs = - source - |> Map.get("pinned_outputs") || Map.get(source, :pinned_outputs) || - %{} - |> normalize_pinned_outputs() - - disabled_steps = - source - |> Map.get("disabled_steps") || Map.get(source, :disabled_steps) || - [] - |> List.wrap() - - disabled_mode = - Map.get(source, "disabled_mode") || Map.get(source, :disabled_mode) || %{} - - %__MODULE__{ - workflow_id: workflow_id, - pinned_outputs: pinned_outputs, - disabled_steps: MapSet.new(disabled_steps), - disabled_mode: disabled_mode - } - end - - def from_storage(workflow_id, _editor_state, _settings), - do: %__MODULE__{workflow_id: workflow_id} - - def from_settings(workflow_id, settings) when is_map(settings) do - from_storage(workflow_id, %{}, settings) - end - - def from_settings(workflow_id, _settings), - do: %__MODULE__{workflow_id: workflow_id} - - defp normalize_pinned_outputs(outputs) when is_map(outputs) do - Map.new(outputs, fn {step_id, output} -> - {to_string(step_id), Serializer.wrap_for_db(output)} - end) - end - - defp normalize_pinned_outputs(_outputs), do: %{} - - defp resolve_editor_state(settings) do - case Map.get(settings, "editor_state") || Map.get(settings, :editor_state) do - nil -> - if has_editor_state_keys?(settings) do - settings - else - %{} - end - - editor_state -> - editor_state - end - end - - defp has_editor_state_keys?(settings) do - Enum.any?([:pinned_outputs, :disabled_steps, :disabled_mode], fn key -> - Map.has_key?(settings, key) || Map.has_key?(settings, Atom.to_string(key)) - end) - end - - def acquire_lock(state, step_id, user_id) do - now = DateTime.utc_now() - - case Map.get(state.step_locks, step_id) do - nil -> - {:ok, put_lock(state, step_id, user_id, now)} - - ^user_id -> - # Already locked by same user - refresh - {:ok, put_lock(state, step_id, user_id, now)} - - other_user_id -> - # Check if lock has expired - lock_time = Map.get(state.lock_timestamps, step_id) - - if DateTime.diff(now, lock_time, :millisecond) > @lock_timeout_ms do - {:ok, put_lock(state, step_id, user_id, now)} - else - {:locked, other_user_id} - end - end - end - - def release_lock(state, step_id, user_id) do - case Map.get(state.step_locks, step_id) do - ^user_id -> - release_lock(state, step_id) - - _ -> - state - end - end - - def release_lock(state, step_id) do - %{ - state - | step_locks: Map.delete(state.step_locks, step_id), - lock_timestamps: Map.delete(state.lock_timestamps, step_id) - } - end - - def put_lock(state, step_id, user_id, timestamp) do - %{ - state - | step_locks: Map.put(state.step_locks, step_id, user_id), - lock_timestamps: Map.put(state.lock_timestamps, step_id, timestamp) - } - end -end diff --git a/lib/fizz/collaboration/editor_state_encoder.ex b/lib/fizz/collaboration/editor_state_encoder.ex deleted file mode 100644 index fd1a970..0000000 --- a/lib/fizz/collaboration/editor_state_encoder.ex +++ /dev/null @@ -1,13 +0,0 @@ -defimpl LiveVue.Encoder, for: Fizz.Collaboration.EditorState do - def encode(state, opts) do - data = %{ - workflow_id: state.workflow_id, - pinned_outputs: state.pinned_outputs, - disabled_steps: MapSet.to_list(state.disabled_steps), - step_locks: state.step_locks, - webhook_test: state.webhook_test - } - - LiveVue.Encoder.encode(data, opts) - end -end diff --git a/lib/fizz/collaboration/preview_execution.ex b/lib/fizz/collaboration/preview_execution.ex deleted file mode 100644 index 3bc8060..0000000 --- a/lib/fizz/collaboration/preview_execution.ex +++ /dev/null @@ -1,210 +0,0 @@ -defmodule Fizz.Collaboration.PreviewExecution do - @moduledoc """ - Builds and runs preview executions that incorporate editor state - (pins, disabled steps, partial execution). - """ - - alias Fizz.Collaboration.EditSession.Server, as: EditServer - alias Fizz.Collaboration.EditorState - alias Fizz.Graph - alias Fizz.Runtime.ExecutionContext - alias Fizz.Runtime.RunicAdapter - alias Fizz.Executions - alias Fizz.Accounts.Scope - - @type execution_mode :: :full | :from_step | :to_step | :selected - - @type preview_opts :: [ - mode: execution_mode(), - target_steps: [String.t()], - input_data: map() - ] - - @doc """ - Run a preview execution with editor state applied. - - This: - 1. Gets current draft and editor state from the session - 2. Applies disabled steps (skip or exclude) - 3. Injects pinned outputs - 4. Builds execution subgraph based on mode - 5. Runs the execution - """ - @spec run(String.t(), Scope.t(), preview_opts()) :: - {:ok, Executions.Execution.t()} | {:error, term()} - def run(workflow_id, scope, opts \\ []) do - mode = Keyword.get(opts, :mode, :full) - target_steps = Keyword.get(opts, :target_steps, []) - input_data = Keyword.get(opts, :input_data, %{}) - - with {:ok, draft} <- get_draft(scope, workflow_id), - {:ok, editor_state} <- get_editor_state(workflow_id), - {:ok, effective_draft} <- apply_editor_state(draft, editor_state, mode, target_steps), - {:ok, execution} <- - create_preview_execution(workflow_id, scope, effective_draft, editor_state, input_data) do - # Run synchronously for preview (could also queue) - run_execution(execution, effective_draft, editor_state, scope) - end - end - - defp get_draft(scope, workflow_id) do - case Fizz.Workflows.get_draft(scope, workflow_id) do - {:error, :not_found} -> {:error, :draft_not_found} - {:ok, draft} -> {:ok, draft} - end - end - - defp get_editor_state(workflow_id) do - # Check if the session process exists - case Registry.lookup(Fizz.Collaboration.EditSession.Registry, workflow_id) do - [{_pid, _}] -> - # Process exists, try to get state - try do - EditServer.get_editor_state(workflow_id) - rescue - _ -> {:ok, %EditorState{workflow_id: workflow_id}} - end - - [] -> - # No process, return default state - {:ok, %EditorState{workflow_id: workflow_id}} - end - end - - defp apply_editor_state(draft, editor_state, mode, target_steps) do - # 1. Remove or bypass disabled steps - steps = apply_disabled_steps(draft.steps, editor_state) - - # 2. Filter connections for remaining steps - step_ids = MapSet.new(Enum.map(steps, & &1.id)) - - connections = - Enum.filter(draft.connections, fn conn -> - MapSet.member?(step_ids, conn.source_step_id) and - MapSet.member?(step_ids, conn.target_step_id) - end) - - # 3. Build subgraph based on execution mode - {steps, connections} = build_subgraph(steps, connections, mode, target_steps) - - {:ok, %{draft | steps: steps, connections: connections}} - end - - defp apply_disabled_steps(steps, editor_state) do - # For now, just exclude disabled steps entirely - # Could implement bypass mode here - Enum.reject(steps, fn step -> - MapSet.member?(editor_state.disabled_steps, step.id) - end) - end - - defp build_subgraph(steps, connections, :full, _targets) do - {steps, connections} - end - - defp build_subgraph(steps, connections, :from_step, [target_id]) do - graph = Graph.from_workflow!(steps, connections) - downstream = Graph.downstream(graph, target_id) - keep_ids = MapSet.new([target_id | downstream]) - - filtered_steps = Enum.filter(steps, &MapSet.member?(keep_ids, &1.id)) - - filtered_connections = - Enum.filter(connections, fn conn -> - MapSet.member?(keep_ids, conn.source_step_id) and - MapSet.member?(keep_ids, conn.target_step_id) - end) - - {filtered_steps, filtered_connections} - end - - defp build_subgraph(steps, connections, :to_step, [target_id]) do - graph = Graph.from_workflow!(steps, connections) - upstream = Graph.upstream(graph, target_id) - keep_ids = MapSet.new([target_id | upstream]) - - filtered_steps = Enum.filter(steps, &MapSet.member?(keep_ids, &1.id)) - - filtered_connections = - Enum.filter(connections, fn conn -> - MapSet.member?(keep_ids, conn.source_step_id) and - MapSet.member?(keep_ids, conn.target_step_id) - end) - - {filtered_steps, filtered_connections} - end - - defp build_subgraph(steps, connections, :selected, target_ids) do - keep_ids = MapSet.new(target_ids) - - filtered_steps = Enum.filter(steps, &MapSet.member?(keep_ids, &1.id)) - - filtered_connections = - Enum.filter(connections, fn conn -> - MapSet.member?(keep_ids, conn.source_step_id) and - MapSet.member?(keep_ids, conn.target_step_id) - end) - - {filtered_steps, filtered_connections} - end - - defp build_subgraph(steps, connections, _unknown_mode, _targets) do - # For unknown modes, default to full execution - {steps, connections} - end - - defp create_preview_execution(workflow_id, scope, _draft, editor_state, input_data) do - # Include pinned outputs in the execution context - initial_context = - editor_state.pinned_outputs - |> Enum.into(%{}, fn {step_id, data} -> {step_id, data} end) - - Executions.create_execution( - scope, - %{ - workflow_id: workflow_id, - execution_type: :preview, - trigger: %{type: :manual, data: input_data}, - context: initial_context, - triggered_by_user_id: scope.user.id, - metadata: %{ - extras: %{ - preview: true, - pinned_steps: Map.keys(editor_state.pinned_outputs), - disabled_steps: MapSet.to_list(editor_state.disabled_steps) - } - } - } - ) - end - - defp run_execution(execution, draft, editor_state, scope) do - # Build Runic workflow with pinned outputs injected - runic_workflow = - RunicAdapter.to_runic_workflow(draft, - execution_id: execution.id, - step_outputs: editor_state.pinned_outputs - ) - - # Execute synchronously for preview - case Runic.Workflow.react_until_satisfied(runic_workflow, %{}) do - result_workflow -> - context = extract_context(result_workflow, editor_state.pinned_outputs) - - case Executions.update_execution_status(scope, execution, :completed, context: context) do - {:ok, updated_execution} -> {:ok, updated_execution} - # Fallback to original if update fails - {:error, _} -> {:ok, execution} - end - end - rescue - e -> - Executions.update_execution_status(scope, execution, :failed, error: Exception.message(e)) - {:error, e} - end - - defp extract_context(workflow, pinned_outputs) do - ctx = ExecutionContext.from_runic_workflow(workflow) - Map.merge(pinned_outputs || %{}, ctx.step_outputs) - end -end diff --git a/lib/fizz/executions.ex b/lib/fizz/executions.ex index 486c362..c7ddccf 100644 --- a/lib/fizz/executions.ex +++ b/lib/fizz/executions.ex @@ -13,7 +13,7 @@ defmodule Fizz.Executions do alias Fizz.Executions.{Execution, Events, StepExecution} alias Fizz.Workflows.Workflow alias Fizz.Accounts.Scope - alias Fizz.Runtime.Serializer + alias Fizz.Serializer @active_step_statuses [:pending, :queued, :running] @active_status_rank %{pending: 0, queued: 1, running: 2} diff --git a/lib/fizz/executions/events.ex b/lib/fizz/executions/events.ex index 20482cd..9599e38 100644 --- a/lib/fizz/executions/events.ex +++ b/lib/fizz/executions/events.ex @@ -8,7 +8,7 @@ defmodule Fizz.Executions.Events do require Logger alias Fizz.Executions.PubSub - alias Fizz.Runtime.Serializer + alias Fizz.Serializer @execution_lifecycle_events [ :execution_started, diff --git a/lib/fizz/runtime/execution/server.ex b/lib/fizz/runtime/execution/server.ex deleted file mode 100644 index 6db672d..0000000 --- a/lib/fizz/runtime/execution/server.ex +++ /dev/null @@ -1,531 +0,0 @@ -defmodule Fizz.Runtime.Execution.Server do - @moduledoc """ - OTP process representing a single workflow execution. - Uses Runic as the core dataflow engine. - """ - use GenServer, restart: :temporary - - require Logger - alias Runic.Workflow - alias Fizz.Accounts.Scope - alias Fizz.Executions.Events - alias Fizz.Runtime.RunicAdapter - alias Fizz.Runtime.Hooks.Observability - alias Fizz.Executions - alias Fizz.Executions.Execution - import Ecto.Query, warn: false - alias Fizz.Repo - - defmodule State do - defstruct [ - :execution_id, - :runic_workflow, - :status, - :metadata, - :runtime_opts, - :trigger_data - ] - end - - # ============================================================================ - # API - # ============================================================================ - - def start_link(opts) do - execution_id = Keyword.fetch!(opts, :execution_id) - GenServer.start_link(__MODULE__, opts, name: via_tuple(execution_id)) - end - - defp via_tuple(execution_id) do - {:via, Registry, {Fizz.Runtime.Execution.Registry, execution_id}} - end - - # ============================================================================ - # Callbacks - # ============================================================================ - - @impl true - def init(opts) do - execution_id = Keyword.fetch!(opts, :execution_id) - runtime_opts = Keyword.drop(opts, [:execution_id]) - - Logger.metadata(execution_id: execution_id) - Logger.info("Initializing execution server") - - Process.flag(:trap_exit, true) - - try do - case load_with_source(execution_id) do - {:ok, execution} -> - case build_runic_workflow(execution, runtime_opts) do - {:ok, runic_wrk} -> - state = %State{ - execution_id: execution_id, - runic_workflow: runic_wrk, - status: execution.status, - metadata: execution.metadata, - runtime_opts: runtime_opts, - trigger_data: (execution.trigger && execution.trigger.data) || %{} - } - - # Trigger execution if process was started for a pending/running execution - case execution.status do - s when s in [:pending, :running] -> - send(self(), :run) - - _ -> - :ok - end - - {:ok, state} - - {:error, :missing_source} -> - handle_init_failure(execution_id, :missing_source) - {:stop, :missing_source} - end - - {:error, :not_found} -> - {:stop, :not_found} - end - rescue - e -> - Logger.error("Execution server failed to initialize: #{inspect(e)}", - stacktrace: __STACKTRACE__ - ) - - handle_init_failure(execution_id, e) - {:stop, :init_failure} - end - end - - @impl true - def terminate(reason, state) do - # If the process is terminating abnormally and hasn't reached a terminal state - # We ignore :normal, :shutdown, and {:shutdown, :normal} - execution_id = - case state do - %State{execution_id: id} -> id - _ -> nil - end - - if (execution_id && reason not in [:normal, :shutdown]) and not match?({:shutdown, _}, reason) do - case Repo.get(Execution, execution_id) do - %Execution{status: status} = execution - when status not in [:completed, :failed, :cancelled] -> - Logger.error("Execution server terminating unexpectedly: #{inspect(reason)}") - error_map = Execution.format_error(reason) - - execution - |> Execution.changeset(%{ - status: :failed, - error: error_map, - completed_at: DateTime.utc_now() - }) - |> Repo.update() - - Events.emit( - :execution_failed, - execution_id, - %{status: :failed, error: error_map}, - workflow_id: execution.workflow_id, - source: :execution_server - ) - - # Also cancel any active steps - Executions.cancel_active_step_executions(execution_id) - - _ -> - :ok - end - end - - case state do - %State{} when not is_nil(execution_id) -> - :ok - - _ -> - :ok - end - - # Always flush buffered step events before dying - flush_step_executions(execution_id) - - :ok - end - - @impl true - def handle_info(:run, state) do - # Transition to running in DB if pending - if state.status == :pending do - update_status(state.execution_id, :running) - end - - # Emit execution started event - Events.emit( - :execution_started, - state.execution_id, - %{status: :running}, - workflow_id: workflow_id_from_state(state), - source: :execution_server - ) - - # Use cached trigger data from state - trigger_data = state.trigger_data - - # Execute Runic cycles - try do - # For now, we run until completion or wait state. - # Runic's react_until_satisfied is the primary driver. - new_runic_wrk = Workflow.react_until_satisfied(state.runic_workflow, trigger_data) - stop_reason = pending_stop_reason() - - if stop_reason do - new_state = %{state | runic_workflow: new_runic_wrk} - {:noreply, new_state} - else - # Sync the Runic graph results back to the Fizz context - new_state = %{state | runic_workflow: new_runic_wrk, status: :completed} - finalize_execution(new_state) - - # Flush step events to DB - flush_step_executions(state.execution_id) - - # Emit completion event - Events.emit( - :execution_completed, - new_state.execution_id, - %{status: :completed}, - workflow_id: workflow_id_from_state(new_state), - source: :execution_server - ) - - {:stop, :normal, new_state} - end - catch - :throw, {:step_error, step_id, reason} -> - state = handle_failure(state, step_id, reason) - {:stop, :normal, state} - - kind, reason -> - Logger.error("Execution failed unexpectedly: #{inspect(reason)}", - kind: kind, - stacktrace: __STACKTRACE__ - ) - - state = handle_failure(state, "system", reason) - {:stop, :normal, state} - end - end - - defp load_with_source(id) do - execution = - Execution - |> Repo.get(id) - |> Repo.preload(workflow: [:user, :workspace, :draft, :published_version]) - - if execution, do: {:ok, execution}, else: {:error, :not_found} - end - - defp build_runic_workflow(execution, runtime_opts) do - # Hydrate Runic Workflow from source - runic_wrk = build_from_source(execution, runtime_opts) - - if runic_wrk do - # Attach observability hooks for logging, telemetry, events - hooked_wrk = - Observability.attach_all_hooks(runic_wrk, - execution_id: execution.id, - workflow_id: execution.workflow_id - ) - - {:ok, hooked_wrk} - else - {:error, :missing_source} - end - end - - defp build_from_source(execution, runtime_opts) do - source_override = Keyword.get(runtime_opts, :source) || Keyword.get(runtime_opts, :draft) - - case source_override || get_source(execution) do - nil -> - nil - - source -> - # Merge runtime opts (like ephemeral PIDs) into metadata - runtime_metadata = - runtime_opts - |> Keyword.drop([:source, :draft]) - |> Map.new() - - metadata = - %{ - trace_id: - (execution.metadata && Map.get(execution.metadata, :trace_id)) || - Map.get(execution.metadata || %{}, "trace_id"), - workflow_id: execution.workflow_id - } - |> Map.merge(runtime_metadata) - - # Pass execution context to the adapter - opts = [ - execution_id: execution.id, - variables: - (execution.metadata && Map.get(execution.metadata, :variables)) || - Map.get(execution.metadata || %{}, "variables", %{}), - trigger_data: (execution.trigger && execution.trigger.data) || %{}, - trigger_type: (execution.trigger && execution.trigger.type) || :manual, - metadata: metadata, - step_outputs: Keyword.get(runtime_opts, :step_outputs, %{}), - scope: build_execution_scope(execution.workflow) - ] - - RunicAdapter.to_runic_workflow(source, opts) - end - end - - defp build_execution_scope(%{user: user, workspace: workspace}) do - user - |> Scope.for_user() - |> maybe_put_scope_workspace(workspace) - |> maybe_put_scope_organization_id(workspace) - end - - defp build_execution_scope(%{user: user}), do: Scope.for_user(user) - defp build_execution_scope(_workflow), do: nil - - defp maybe_put_scope_workspace(%Scope{} = scope, %{id: _workspace_id} = workspace) do - Scope.with_workspace(scope, workspace) - end - - defp maybe_put_scope_workspace(%Scope{} = scope, _workspace), do: scope - defp maybe_put_scope_workspace(_scope, _workspace), do: nil - - defp maybe_put_scope_organization_id( - %Scope{} = scope, - %{workos_organization_id: organization_id} - ) - when is_binary(organization_id) and byte_size(organization_id) > 0 do - Scope.with_organization_id(scope, organization_id) - end - - defp maybe_put_scope_organization_id(%Scope{} = scope, _workspace), do: scope - defp maybe_put_scope_organization_id(_scope, _workspace), do: nil - - defp get_source(%Execution{ - execution_type: :production, - workflow: %{published_version: version} - }) - when not is_nil(version), - do: version - - defp get_source(%Execution{workflow: %{draft: draft}}), do: draft - defp get_source(_), do: nil - - defp handle_init_failure(execution_id, reason) do - error_map = Execution.format_error(reason) - - execution = Repo.get!(Execution, execution_id) - - execution - |> Execution.changeset(%{ - status: :failed, - error: error_map, - completed_at: DateTime.utc_now() - }) - |> Repo.update!() - - Events.emit( - :execution_failed, - execution_id, - %{status: :failed, error: error_map}, - workflow_id: execution.workflow_id, - source: :execution_server - ) - end - - defp update_status(id, status) do - Task.start(fn -> - now = DateTime.utc_now() - - Repo.update_all( - from(e in Execution, where: e.id == ^id), - set: [status: status, started_at: now, updated_at: now] - ) - end) - end - - defp finalize_execution(state) do - context = Process.get(:fizz_accumulated_outputs, %{}) - - Task.start(fn -> - now = DateTime.utc_now() - - Repo.update_all( - from(e in Execution, where: e.id == ^state.execution_id), - set: [ - status: :completed, - context: context, - completed_at: now, - updated_at: now - ] - ) - end) - - Logger.info("Execution completed successfully") - end - - defp handle_failure(state, step_id, reason) do - error_map = Execution.format_error({:step_failed, step_id, reason}) - completed_at = DateTime.utc_now() |> DateTime.truncate(:microsecond) - - Observability.push_step_event(%{ - execution_id: state.execution_id, - step_id: step_id, - status: :failed, - error: error_map, - completed_at: completed_at - }) - - step_execution = - case Executions.record_step_execution_failed_by_step(state.execution_id, step_id, reason) do - {:ok, step_execution} -> - step_execution - - {:error, failure_reason} -> - Logger.warning("Failed to persist step execution failure", - execution_id: state.execution_id, - step_id: step_id, - reason: inspect(failure_reason) - ) - - nil - end - - # Cancel any other steps that might be running/pending - Executions.cancel_active_step_executions(state.execution_id) - - payload = - %{ - execution_id: state.execution_id, - step_id: step_id, - status: :failed, - error: error_map, - completed_at: (step_execution && step_execution.completed_at) || completed_at - } - |> maybe_put_step_type(step_execution) - - Fizz.Executions.PubSub.broadcast_step(:step_failed, state.execution_id, nil, payload) - - Execution - |> Repo.get!(state.execution_id) - |> Execution.changeset(%{ - status: :failed, - error: error_map, - completed_at: completed_at - }) - |> Repo.update!() - - # Emit execution failed event - Events.emit( - :execution_failed, - state.execution_id, - %{status: :failed, error: error_map}, - workflow_id: workflow_id_from_state(state), - source: :execution_server - ) - - # Flush step events to DB - flush_step_executions(state.execution_id) - - Logger.error("Execution failed at step #{step_id}: #{inspect(reason)}") - - state - end - - defp maybe_put_step_type(payload, %{step_type_id: step_type_id}) - when not is_nil(step_type_id) do - Map.put(payload, :step_type_id, step_type_id) - end - - defp maybe_put_step_type(payload, step_execution) when is_map(step_execution) do - case step_execution do - %{step_type_id: step_type_id} when not is_nil(step_type_id) -> - Map.put(payload, :step_type_id, step_type_id) - - _ -> - payload - end - end - - defp maybe_put_step_type(payload, _step_execution), do: payload - - defp flush_step_executions(execution_id) do - events = Observability.flush_step_events() - - if events != [] do - # If the execution was cancelled, mark any active steps as cancelled - execution = Repo.get(Execution, execution_id) - - events = - if execution && execution.status == :cancelled do - Enum.map(events, fn e -> - cond do - e.status == :running -> - Map.put(e, :status, :cancelled) - - cancel_completed_after?(e, execution.completed_at) -> - Map.put(e, :status, :cancelled) - - true -> - e - end - end) - else - events - end - - case Executions.record_step_executions_batch(events) do - {:ok, _count} -> - :ok - - {:error, reason} -> - Logger.warning("Failed to flush step executions batch", reason: inspect(reason)) - end - end - end - - defp pending_stop_reason do - case Process.info(self(), :messages) do - {:messages, messages} -> - Enum.find_value(messages, fn - {:system, _from, {:terminate, reason}} -> reason - _ -> nil - end) - - _ -> - nil - end - end - - defp cancel_completed_after?(_event, nil), do: false - - defp cancel_completed_after?(event, %DateTime{} = cancelled_at) do - completed_at = - case Map.get(event, :completed_at) || Map.get(event, "completed_at") do - %DateTime{} = dt -> dt - _ -> nil - end - - case completed_at do - %DateTime{} = dt -> DateTime.compare(dt, cancelled_at) in [:gt, :eq] - _ -> false - end - end - - defp workflow_id_from_state(%State{metadata: metadata}) when is_map(metadata) do - Map.get(metadata, :workflow_id) || Map.get(metadata, "workflow_id") - end - - defp workflow_id_from_state(_state), do: nil -end diff --git a/lib/fizz/runtime/execution/supervisor.ex b/lib/fizz/runtime/execution/supervisor.ex deleted file mode 100644 index 976deab..0000000 --- a/lib/fizz/runtime/execution/supervisor.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Fizz.Runtime.Execution.Supervisor do - @moduledoc """ - DynamicSupervisor for workflow execution processes. - """ - use DynamicSupervisor - - alias Fizz.Runtime.Execution.Server - - def start_link(init_arg) do - DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) - end - - @impl true - def init(_init_arg) do - DynamicSupervisor.init(strategy: :one_for_one) - end - - @doc """ - Starts a new execution process. - """ - def start_execution(execution_id, opts \\ []) do - DynamicSupervisor.start_child(__MODULE__, {Server, [execution_id: execution_id] ++ opts}) - end - - @doc """ - Finds the pid of a running execution. - """ - def get_execution_pid(execution_id) do - case Registry.lookup(Fizz.Runtime.Execution.Registry, execution_id) do - [{pid, _}] -> {:ok, pid} - [] -> {:error, :not_found} - end - end -end diff --git a/lib/fizz/runtime/execution_context.ex b/lib/fizz/runtime/execution_context.ex deleted file mode 100644 index 82ac3fa..0000000 --- a/lib/fizz/runtime/execution_context.ex +++ /dev/null @@ -1,125 +0,0 @@ -defmodule Fizz.Runtime.ExecutionContext do - @moduledoc """ - Rich context for step execution within a Runic workflow. - - This struct provides all the context a step executor needs, built from - Runic's workflow state and fact ancestry. - - ## Fields - - - `:execution_id` - The Fizz Execution record ID - - `:workflow_id` - The source workflow ID - - `:step_id` - The current step being executed - - `:step_outputs` - Map of step_id => output for all completed steps - - `:variables` - Workflow-level variables - - `:metadata` - Execution metadata (trace_id, etc.) - - `:scope` - Authenticated caller scope for execution-time integrations - - `:input` - The input value for this step (from parent facts) - """ - - alias Fizz.Accounts.Scope - - @type t :: %__MODULE__{ - execution_id: String.t() | nil, - workflow_id: String.t() | nil, - step_id: String.t() | nil, - step_outputs: %{String.t() => term()}, - variables: map(), - metadata: map(), - scope: Scope.t() | nil, - input: term(), - trigger: term(), - trigger_type: atom() | nil, - request: map() - } - - defstruct [ - :execution_id, - :workflow_id, - :step_id, - step_outputs: %{}, - variables: %{}, - metadata: %{}, - scope: nil, - input: nil, - trigger: nil, - trigger_type: nil, - request: %{} - ] - - @doc """ - Builds an ExecutionContext from Runic workflow state. - - Extracts step outputs by traversing the workflow's graph for `:produced` edges. - """ - @spec from_runic_workflow(Runic.Workflow.t(), map()) :: t() - def from_runic_workflow(workflow, opts \\ %{}) do - step_outputs = extract_step_outputs(workflow) - - %__MODULE__{ - execution_id: Map.get(opts, :execution_id), - workflow_id: Map.get(opts, :workflow_id), - step_id: Map.get(opts, :step_id), - step_outputs: step_outputs, - variables: Map.get(opts, :variables, %{}), - metadata: Map.get(opts, :metadata, %{}), - scope: Map.get(opts, :scope), - input: Map.get(opts, :input), - trigger: Map.get(opts, :trigger), - trigger_type: Map.get(opts, :trigger_type), - request: Map.get(opts, :request, %{}) - } - end - - @doc """ - Creates a minimal context for testing or simple execution. - """ - @spec new(keyword()) :: t() - def new(opts \\ []) do - struct(__MODULE__, opts) - end - - @doc """ - Updates the context with output from a completed step. - """ - @spec put_output(t(), String.t(), term()) :: t() - def put_output(%__MODULE__{} = ctx, step_id, output) do - %{ctx | step_outputs: Map.put(ctx.step_outputs, step_id, output)} - end - - @doc """ - Gets the output of a previously completed step. - """ - @spec get_output(t(), String.t()) :: term() | nil - def get_output(%__MODULE__{step_outputs: outputs}, step_id) do - Map.get(outputs, step_id) - end - - # =========================================================================== - # Private Helpers - # =========================================================================== - - defp extract_step_outputs(workflow) do - graph = workflow.graph - - # Find all Facts with :produced edges from Steps - graph - |> Graph.vertices() - |> Enum.filter(&match?(%Runic.Workflow.Fact{}, &1)) - |> Enum.reduce(%{}, fn fact, acc -> - producing_step = - graph - |> Graph.in_neighbors(fact) - |> Enum.find(&match?(%Runic.Workflow.Step{}, &1)) - - case producing_step do - %{name: name} when is_binary(name) -> - # Step ID (name in Runic) is now the key-safe slug, use directly - Map.put(acc, name, fact.value) - - _ -> - acc - end - end) - end -end diff --git a/lib/fizz/runtime/expression.ex b/lib/fizz/runtime/expression.ex index 4e3f84c..49968a4 100644 --- a/lib/fizz/runtime/expression.ex +++ b/lib/fizz/runtime/expression.ex @@ -68,7 +68,7 @@ defmodule Fizz.Runtime.Expression do strict_variables: false, strict_filters: true, timeout_ms: 5_000, - state_store: Fizz.Runtime.ExecutionState + state_store: %{} ] # Pattern to detect if a string contains Liquid expressions diff --git a/lib/fizz/runtime/expression/context.ex b/lib/fizz/runtime/expression/context.ex index 4c236dc..87aca29 100644 --- a/lib/fizz/runtime/expression/context.ex +++ b/lib/fizz/runtime/expression/context.ex @@ -88,31 +88,41 @@ defmodule Fizz.Runtime.Expression.Context do @doc """ Builds a variable map from an ExecutionContext. """ - def build_from_context(%Fizz.Runtime.ExecutionContext{} = ctx) do - input = normalize_value(ctx.input) + def build_from_context(ctx) when is_map(ctx) do + input = normalize_value(read_context_field(ctx, :input)) + step_outputs = read_context_field(ctx, :step_outputs, %{}) + trigger = read_context_field(ctx, :trigger) + trigger_type = read_context_field(ctx, :trigger_type) || "unknown" + execution_id = read_context_field(ctx, :execution_id) + workflow_id = read_context_field(ctx, :workflow_id) + variables = read_context_field(ctx, :variables, %{}) + metadata = read_context_field(ctx, :metadata, %{}) + request = read_context_field(ctx, :request, %{}) %{ "json" => input, "input" => input, - "steps" => build_steps_map(ctx.step_outputs), + "steps" => build_steps_map(step_outputs), "execution" => %{ - "id" => ctx.execution_id, - "trigger_type" => to_string(ctx.trigger_type || "unknown"), - "trigger_data" => normalize_value(ctx.trigger) + "id" => execution_id, + "trigger_type" => to_string(trigger_type), + "trigger_data" => normalize_value(trigger) }, "workflow" => %{ - "id" => ctx.workflow_id + "id" => workflow_id }, - "variables" => normalize_map(ctx.variables), - "metadata" => normalize_map(ctx.metadata), - "request" => normalize_map(ctx.request), - "trigger" => normalize_value(ctx.trigger), + "variables" => normalize_map(variables), + "metadata" => normalize_map(metadata), + "request" => normalize_map(request), + "trigger" => normalize_value(trigger), "env" => build_env_map(), "now" => DateTime.utc_now() |> DateTime.to_iso8601(), "today" => Date.utc_today() |> Date.to_iso8601() } end + def build_from_context(_ctx), do: build_minimal() + @doc """ Builds a minimal context for testing or simple evaluations. """ @@ -300,6 +310,10 @@ defmodule Fizz.Runtime.Expression.Context do def normalize_value(value), do: value + defp read_context_field(map, key, default \\ nil) when is_map(map) do + Map.get(map, key, Map.get(map, Atom.to_string(key), default)) + end + defp normalize_map(map) when is_map(map) do Map.new(map, fn {k, v} -> key = if is_atom(k), do: Atom.to_string(k), else: to_string(k) diff --git a/lib/fizz/runtime/hooks/observability.ex b/lib/fizz/runtime/hooks/observability.ex deleted file mode 100644 index ce68df2..0000000 --- a/lib/fizz/runtime/hooks/observability.ex +++ /dev/null @@ -1,459 +0,0 @@ -defmodule Fizz.Runtime.Hooks.Observability do - @moduledoc """ - Runic workflow hooks for observability: logging, telemetry, and events. - - These hooks are attached to Runic workflows to provide: - - Structured logging at step entry/exit - - Telemetry events for metrics collection - - PubSub events for real-time UI updates - - Production-aware item counting - - ## Usage - - workflow - |> Observability.attach_all_hooks(execution_id: "exec_123") - - ## Production Counting - - Item counts are tracked via `Fizz.Runtime.ProductionsCounter` which - understands Runic's production semantics: - - - FanOut steps: count = number of items produced (list length) - - FanIn/Reduce steps: count = 1 (single aggregated result) - - Regular steps: count = 1 (single output) - - Skipped steps: count = 0 - """ - - require Logger - alias Runic.Workflow - alias Fizz.Runtime.StepExecutionState - - @type hook_opts :: [execution_id: String.t(), workflow_id: String.t()] - - @doc """ - Attaches all observability hooks to a Runic workflow. - """ - @spec attach_all_hooks(Workflow.t(), hook_opts()) :: Workflow.t() - def attach_all_hooks(workflow, opts \\ []) do - execution_id = Keyword.get(opts, :execution_id, "unknown") - workflow_id = Keyword.get(opts, :workflow_id, "unknown") - _skip_production_init? = Keyword.get(opts, :skip_production_init, false) - - # Store context in workflow metadata for access in hooks - workflow = put_hook_context(workflow, execution_id, workflow_id) - - # Attach hooks to all steps - workflow - |> attach_context_hooks() - |> attach_logging_hooks() - |> attach_telemetry_hooks(execution_id) - end - - @doc """ - Attaches context hooks that pass accumulated step outputs to step functions. - """ - @spec attach_context_hooks(Workflow.t()) :: Workflow.t() - def attach_context_hooks(workflow) do - workflow.components - |> Enum.reduce(workflow, fn {component_name, _component}, wf -> - Workflow.attach_before_hook(wf, component_name, &before_step_context/3) - end) - end - - @doc """ - Attaches logging hooks that log step entry and exit. - """ - @spec attach_logging_hooks(Workflow.t()) :: Workflow.t() - def attach_logging_hooks(workflow) do - # Attach logging hooks to all steps - workflow.components - |> Enum.reduce(workflow, fn {component_name, _component}, wf -> - wf - |> Workflow.attach_before_hook(component_name, &before_step_logging/3) - |> Workflow.attach_after_hook(component_name, &after_step_logging/3) - end) - end - - @doc """ - Attaches telemetry hooks for metrics collection. - """ - @spec attach_telemetry_hooks(Workflow.t(), String.t()) :: Workflow.t() - def attach_telemetry_hooks(workflow, execution_id) do - # We use a closure to capture execution_id - before_fn = fn step, wf, fact -> - before_step_telemetry(step, wf, fact, execution_id) - end - - after_fn = fn step, wf, fact -> - after_step_telemetry(step, wf, fact, execution_id) - end - - # Attach telemetry hooks to all steps - workflow.components - |> Enum.reduce(workflow, fn {component_name, _component}, wf -> - wf - |> Workflow.attach_before_hook(component_name, before_fn) - |> Workflow.attach_after_hook(component_name, after_fn) - end) - end - - # =========================================================================== - # Before Hooks - # =========================================================================== - - # Pass accumulated step outputs to the step function via process dictionary - defp before_step_context(_step, workflow, _fact) do - outputs = Process.get(:fizz_accumulated_outputs, %{}) - Process.put(:fizz_step_outputs, outputs) - workflow - end - - defp before_step_logging(_step, workflow, _fact) do - # Logging removed for performance - workflow - end - - defp before_step_telemetry(step, workflow, fact, execution_id) do - if skip_observability?(step, workflow) do - workflow - else - step_name = get_step_name(step) - start_time = System.monotonic_time() - started_at = DateTime.utc_now() - step_type_id = get_step_type_id(step, workflow) - original_step_id = get_original_step_id(step, workflow) - - # Use the fact's item context directly instead of global process state - # Each fact carries its own item_index and items_total from its originating FanOut - item_index = fact.item_index - items_total = fact.items_total - - # Store the current fan-out context for the after_step_telemetry hook - if item_index do - Process.put(:fizz_fan_out_context, %{item_index: item_index, items_total: items_total}) - else - Process.delete(:fizz_fan_out_context) - end - - # Store start time and input for duration and complete payloads - workflow = - workflow - |> put_step_start_time(step_name, start_time) - |> put_step_started_at(step_name, started_at) - |> put_step_input_data(step_name, fact.value) - |> put_step_semantic_type(step_name, classify_step_type(step)) - - # Buffer the "started" event instead of persisting immediately - push_step_event(%{ - execution_id: execution_id, - step_id: original_step_id, - step_type_id: step_type_id || "unknown", - status: :running, - input_data: fact.value, - item_index: item_index, - items_total: items_total, - started_at: started_at - }) - - state = - StepExecutionState.started(execution_id, original_step_id, fact.value, - step_type_id: step_type_id, - item_index: item_index, - items_total: items_total, - started_at: started_at - ) - - Fizz.Executions.PubSub.broadcast_step(:step_started, execution_id, nil, state) - - workflow - end - end - - # =========================================================================== - # After Hooks - # =========================================================================== - - defp after_step_logging(_step, workflow, _result_fact) do - # Logging removed for performance - workflow - end - - defp after_step_telemetry(step, workflow, result_fact, execution_id) do - if skip_observability?(step, workflow) do - skipped? = Process.get(:fizz_step_skipped, false) - Process.delete(:fizz_step_skipped) - - unless skipped? do - metadata = get_step_metadata(step, workflow) - - if metadata[:step_id] do - step_name = get_step_name(step) - acc_outputs = Process.get(:fizz_accumulated_outputs, %{}) - - Process.put( - :fizz_accumulated_outputs, - Map.put(acc_outputs, step_name, result_fact.value) - ) - end - end - - workflow - else - step_name = get_step_name(step) - step_type_id = get_step_type_id(step, workflow) - original_step_id = get_original_step_id(step, workflow) - - # Calculate duration - start_time = get_step_start_time(workflow, step_name) - duration_us = if start_time, do: System.monotonic_time() - start_time, else: 0 - duration_us = System.convert_time_unit(duration_us, :native, :microsecond) - - # Check if step was skipped via process flag - skipped? = Process.get(:fizz_step_skipped, false) - Process.delete(:fizz_step_skipped) - - # Get fan-out context if we're processing an item in a fan-out batch - fan_out_ctx = Process.get(:fizz_fan_out_context) - item_index = if fan_out_ctx, do: fan_out_ctx[:item_index], else: nil - items_total = if fan_out_ctx, do: fan_out_ctx[:items_total], else: nil - - # Get the semantic step type for production counting - semantic_type = get_step_semantic_type(workflow, step_name) - - # Calculate output item count directly from fact data - output_item_count = calculate_output_item_count(result_fact, semantic_type, skipped?) - - input_data = get_step_input_data(workflow, step_name) - started_at = get_step_started_at(workflow, step_name) - - # Buffer the "completed" / "skipped" event - push_step_event(%{ - execution_id: execution_id, - step_id: original_step_id, - step_type_id: step_type_id || "unknown", - status: if(skipped?, do: :skipped, else: :completed), - input_data: input_data, - output_data: result_fact.value, - output_item_count: output_item_count, - item_index: item_index, - items_total: items_total, - started_at: started_at, - completed_at: DateTime.utc_now() - }) - - unless skipped? do - acc_outputs = Process.get(:fizz_accumulated_outputs, %{}) - Process.put(:fizz_accumulated_outputs, Map.put(acc_outputs, step_name, result_fact.value)) - end - - state_opts = [ - step_type_id: step_type_id, - duration_us: duration_us, - item_index: item_index, - items_total: items_total, - started_at: started_at, - completed_at: DateTime.utc_now(), - output_item_count: output_item_count - ] - - state = - if skipped? do - StepExecutionState.skipped(execution_id, original_step_id, input_data, state_opts) - else - StepExecutionState.completed( - execution_id, - original_step_id, - input_data, - result_fact.value, - state_opts - ) - end - - Fizz.Executions.PubSub.broadcast_step( - if(skipped?, do: :step_skipped, else: :step_completed), - execution_id, - nil, - state - ) - - workflow - end - end - - # =========================================================================== - # Step Type Classification - # =========================================================================== - - @doc """ - Classifies a Runic component into its semantic production type. - - This determines how output item counting should work: - - :fan_out - Produces N items from 1 input (splitter) - - :fan_in - Consumes N items, produces 1 (aggregator via FanIn) - - :reduce - Consumes N items, produces 1 (aggregator via Reduce) - - :regular - 1:1 input to output - """ - @spec classify_step_type(term()) :: :regular | :fan_out | :fan_in | :reduce - def classify_step_type(%Runic.Workflow.FanOut{}), do: :fan_out - def classify_step_type(%Runic.Workflow.FanIn{}), do: :fan_in - def classify_step_type(%Runic.Workflow.Reduce{}), do: :reduce - def classify_step_type(_), do: :regular - - @doc """ - Calculate the output item count for a step based on its result fact and type. - - This replaces the ProductionsCounter logic with direct calculation from Runic fact data. - """ - @spec calculate_output_item_count( - Runic.Workflow.Fact.t(), - :regular | :fan_out | :fan_in | :reduce, - boolean() - ) :: non_neg_integer() - def calculate_output_item_count(_fact, _step_type, true = _skipped), do: 0 - - def calculate_output_item_count(fact, step_type, false = _skipped) do - case {fact.value, step_type} do - # FanOut: count the items produced (length of output list) - {list, :fan_out} when is_list(list) -> - length(list) - - # FanIn/Reduce: always produces single aggregated result - {_, :fan_in} -> - 1 - - {_, :reduce} -> - 1 - - # Regular step: if output is a list, count as 1 (the list itself) - # unless we can detect it's actually a fan-out based on fact metadata - {list, :regular} when is_list(list) -> - # Check if this fact has fan-out metadata indicating it's actually part of a fan-out - if fact.item_index do - # This is a fan-out item, so it produces 1 item - 1 - else - # This is a regular step that outputs a list, count as 1 - 1 - end - - # Nil or empty output - {nil, _} -> - 0 - - {[], _} -> - 0 - - # Any other value = 1 production - {_value, _} -> - 1 - end - end - - # =========================================================================== - # Helpers - # =========================================================================== - - defp get_step_name(%{name: name}) when is_binary(name), do: name - defp get_step_name(%{name: name}) when is_atom(name), do: Atom.to_string(name) - defp get_step_name(_), do: "unknown" - - defp get_step_metadata(step, workflow) do - step_name = get_step_name(step) - - workflow - # LiveView snapshot hack - |> Map.get(:__changed__, %{}) - |> Map.get(:__step_metadata__, workflow |> Map.get(:__step_metadata__, %{})) - |> Map.get(step_name, %{}) - end - - defp skip_observability?(step, workflow) do - metadata = get_step_metadata(step, workflow) - metadata == %{} or metadata[:skip_observability] == true - end - - defp get_step_type_id(step, workflow) do - get_step_metadata(step, workflow)[:type_id] - end - - defp get_original_step_id(step, workflow) do - get_step_metadata(step, workflow)[:step_id] || get_step_name(step) - end - - # Workflow metadata helpers - defp put_hook_context(workflow, execution_id, workflow_id) do - # Store in a way that doesn't interfere with Runic internals - # We use the graph's labeled edge system or a separate holder - # For now, just return the workflow - context is passed via closures - workflow - |> Map.put(:__hook_context__, %{ - execution_id: execution_id, - workflow_id: workflow_id - }) - end - - defp put_step_start_time(workflow, step_name, time) do - times = Map.get(workflow, :__step_times__, %{}) - Map.put(workflow, :__step_times__, Map.put(times, step_name, time)) - end - - defp get_step_start_time(workflow, step_name) do - workflow - |> Map.get(:__step_times__, %{}) - |> Map.get(step_name) - end - - defp put_step_started_at(workflow, step_name, time) do - times = Map.get(workflow, :__step_started_ats__, %{}) - Map.put(workflow, :__step_started_ats__, Map.put(times, step_name, time)) - end - - defp get_step_started_at(workflow, step_name) do - workflow - |> Map.get(:__step_started_ats__, %{}) - |> Map.get(step_name) - end - - defp put_step_input_data(workflow, step_name, data) do - inputs = Map.get(workflow, :__step_inputs__, %{}) - Map.put(workflow, :__step_inputs__, Map.put(inputs, step_name, data)) - end - - defp get_step_input_data(workflow, step_name) do - workflow - |> Map.get(:__step_inputs__, %{}) - |> Map.get(step_name) - end - - defp put_step_semantic_type(workflow, step_name, semantic_type) do - types = Map.get(workflow, :__step_semantic_types__, %{}) - Map.put(workflow, :__step_semantic_types__, Map.put(types, step_name, semantic_type)) - end - - defp get_step_semantic_type(workflow, step_name) do - workflow - |> Map.get(:__step_semantic_types__, %{}) - |> Map.get(step_name, :regular) - end - - # =========================================================================== - # Event Buffering - # =========================================================================== - - @doc """ - Pushes a step event to the process-local buffer for batch persistence. - """ - def push_step_event(event) do - events = Process.get(:fizz_step_events, []) - Process.put(:fizz_step_events, [event | events]) - end - - @doc """ - Retrieves and clears the process-local event buffer. - """ - def flush_step_events do - events = Process.get(:fizz_step_events, []) - Process.put(:fizz_step_events, []) - Enum.reverse(events) - end -end diff --git a/lib/fizz/runtime/runic_adapter.ex b/lib/fizz/runtime/runic_adapter.ex deleted file mode 100644 index 0f706e1..0000000 --- a/lib/fizz/runtime/runic_adapter.ex +++ /dev/null @@ -1,1100 +0,0 @@ -defmodule Fizz.Runtime.RunicAdapter do - @moduledoc """ - Bridges Fizz workflow definitions (Steps/Connections) with the Runic execution engine. - """ - - require Runic - alias Runic.Component - alias Runic.Workflow - alias Runic.Workflow.FanOut - alias Fizz.Accounts.Scope - alias Fizz.Runtime.ExecutionContext - alias Fizz.Runtime.Hooks.Observability - alias Fizz.Runtime.Steps.StepRunner - - @type source :: Fizz.Workflows.WorkflowDraft.t() | map() - @type build_opts :: [ - execution_id: String.t(), - variables: map(), - metadata: map(), - scope: Scope.t() | nil, - step_outputs: map(), - trigger_data: map(), - trigger_type: atom() - ] - - @spec to_runic_workflow(source(), build_opts()) :: Workflow.t() - def to_runic_workflow(source, opts \\ []) do - step_outputs = Keyword.get(opts, :step_outputs, %{}) - steps = source.steps || [] - connections = source.connections || [] - groups = Map.get(source, :groups) || [] - - group_lookup = build_group_lookup(groups) - group_by_id = Map.new(groups, &{&1.id, &1}) - - {outer_steps, outer_connections} = - build_outer_graph(steps, connections, group_lookup, group_by_id) - - graph = Fizz.Graph.from_workflow!(outer_steps, outer_connections, validate: false) - upstream_lookup = build_upstream_lookup(graph) - - # Build step map for looking up step data by ID - step_map = Map.new(outer_steps, &{&1.id, &1}) - slot_bindings = build_slot_binding_lookup(outer_connections) - primary_parent_lookup = build_primary_parent_lookup(outer_connections) - - step_opts = [ - execution_id: Keyword.get(opts, :execution_id), - workflow_id: extract_source_id(source), - variables: Keyword.get(opts, :variables, %{}), - metadata: Keyword.get(opts, :metadata, %{}), - scope: Keyword.get(opts, :scope), - step_outputs: step_outputs, - upstream_lookup: upstream_lookup, - slot_bindings: slot_bindings, - primary_parent_lookup: primary_parent_lookup, - trigger_data: Keyword.get(opts, :trigger_data, %{}), - trigger_type: Keyword.get(opts, :trigger_type), - all_groups: groups - ] - - wrk = Workflow.new(name: "execution_#{extract_source_id(source)}") - parent_lookup = build_parent_lookup(outer_connections) - sorted_steps = topological_sort_steps(outer_steps, outer_connections) - - # Build splitter lookup to detect fan-out paths - splitter_ids = - outer_steps - |> Enum.filter(&(&1.type_id == "splitter")) - |> Enum.map(& &1.id) - |> MapSet.new() - - # Build fan-out path lookup: step_id => fan_out_step_id (which splitter it's downstream of) - fan_out_path_lookup = build_fan_out_path_lookup(graph, splitter_ids, step_map) - - step_opts = - step_opts - |> Keyword.put(:splitter_ids, splitter_ids) - |> Keyword.put(:fan_out_path_lookup, fan_out_path_lookup) - - wrk = - Enum.reduce(sorted_steps, wrk, fn step, acc -> - if step.type_id == "node_group" do - group = Map.fetch!(group_by_id, step.id) - add_group_to_workflow(group, steps, connections, acc, parent_lookup, step_opts) - else - add_step_to_workflow(step, acc, parent_lookup, step_opts) - end - end) - - wrk - |> put_step_metadata(outer_steps, groups) - |> track_all_fan_out_paths() - end - - @spec create_component(Fizz.Workflows.Embeds.Step.t(), String.t(), build_opts()) :: term() - def create_component(step, component_name, opts \\ []) do - case step.type_id do - "splitter" -> - create_splitter(step, component_name, opts) - - "aggregator" -> - # Default to join-style aggregator; fan-out aggregator created in add_step_to_workflow - create_join_aggregator(step, component_name) - - "join" -> - create_explicit_join(step, component_name, opts) - - "condition" -> - create_condition(step, component_name, opts) - - "switch" -> - create_switch(step, component_name, opts) - - _ -> - StepRunner.create(step, opts) - end - end - - # =========================================================================== - # Group Helpers - # =========================================================================== - - defp build_group_lookup(groups) do - groups - |> Enum.flat_map(fn group -> Enum.map(group.step_ids || [], &{&1, group}) end) - |> Map.new() - end - - defp build_outer_graph(steps, connections, group_lookup, group_by_id) do - ungrouped_steps = Enum.reject(steps, &Map.has_key?(group_lookup, &1.id)) - - group_steps = - group_by_id - |> Map.values() - |> Enum.map(&group_step_from_group/1) - - outer_steps = ungrouped_steps ++ group_steps - outer_connections = build_outer_connections(connections, group_lookup) - - {outer_steps, outer_connections} - end - - defp group_step_from_group(group) do - %Fizz.Workflows.Embeds.Step{ - id: group.id, - type_id: "node_group", - name: group.name || group.id, - config: %{}, - position: Map.get(group, :position) || %{}, - notes: nil - } - end - - defp build_outer_connections(connections, group_lookup) do - connections - |> Enum.reduce([], fn conn, acc -> - source_id = connection_field(conn, :source_step_id) - target_id = connection_field(conn, :target_step_id) - source_group = Map.get(group_lookup, source_id) - target_group = Map.get(group_lookup, target_id) - - cond do - source_group && target_group && source_group.id == target_group.id -> - acc - - true -> - new_source = if source_group, do: source_group.id, else: source_id - new_target = if target_group, do: target_group.id, else: target_id - - updated = - conn - |> update_connection(%{source_step_id: new_source, target_step_id: new_target}) - - [updated | acc] - end - end) - |> Enum.reverse() - |> Enum.uniq_by(&connection_identity/1) - end - - defp connection_identity(conn) do - { - connection_field(conn, :source_step_id), - connection_field(conn, :target_step_id), - connection_field(conn, :source_output), - connection_field(conn, :target_input) - } - end - - defp connection_field(conn, key) when is_map(conn) do - Map.get(conn, key) || Map.get(conn, Atom.to_string(key)) - end - - defp update_connection(conn, attrs) when is_map(conn) do - if Map.has_key?(conn, :__struct__) do - struct(conn, attrs) - else - Map.merge(conn, attrs) - end - end - - # =========================================================================== - # Fan-out Path Lookup - # =========================================================================== - - # Build a map of step_id => fan_out_step_id for steps in active splitter paths. - # If a step is reachable by multiple splitters, pick the nearest splitter - # (shortest distance), with deterministic splitter-id tie-breaking. - defp build_fan_out_path_lookup(graph, splitter_ids, step_map) do - splitter_ids - |> Enum.sort() - |> Enum.reduce(%{}, fn splitter_id, acc -> - distances = find_downstream_distances_until_aggregator(graph, splitter_id, step_map) - - Enum.reduce(distances, acc, fn {step_id, distance}, inner_acc -> - put_nearest_splitter(inner_acc, step_id, splitter_id, distance) - end) - end) - |> Map.new(fn {step_id, %{splitter_id: splitter_id}} -> {step_id, splitter_id} end) - end - - defp find_downstream_distances_until_aggregator(graph, start_id, step_map) do - queue = :queue.from_list([{start_id, 0}]) - do_find_downstream_distances(graph, queue, MapSet.new(), %{}, step_map) - end - - defp do_find_downstream_distances(graph, queue, visited, acc, step_map) do - case :queue.out(queue) do - {:empty, _queue} -> - acc - - {{:value, {current, distance}}, queue} -> - case MapSet.member?(visited, current) do - true -> - do_find_downstream_distances(graph, queue, visited, acc, step_map) - - false -> - visited = MapSet.put(visited, current) - acc = put_min_distance(acc, current, distance) - - case aggregator_step?(step_map, current) do - true -> - # Don't traverse past aggregators, but do include them - do_find_downstream_distances(graph, queue, visited, acc, step_map) - - false -> - queue = - graph - |> Fizz.Graph.children(current) - |> Enum.sort() - |> Enum.reduce(queue, fn child, inner_queue -> - :queue.in({child, distance + 1}, inner_queue) - end) - - do_find_downstream_distances(graph, queue, visited, acc, step_map) - end - end - end - end - - defp put_nearest_splitter(acc, step_id, splitter_id, distance) do - case Map.get(acc, step_id) do - nil -> - Map.put(acc, step_id, %{splitter_id: splitter_id, distance: distance}) - - %{splitter_id: current_splitter, distance: current_distance} -> - case {distance < current_distance, - distance == current_distance and splitter_id < current_splitter} do - {true, _} -> - Map.put(acc, step_id, %{splitter_id: splitter_id, distance: distance}) - - {false, true} -> - Map.put(acc, step_id, %{splitter_id: splitter_id, distance: distance}) - - _ -> - acc - end - end - end - - defp put_min_distance(acc, step_id, distance) do - case Map.get(acc, step_id) do - nil -> Map.put(acc, step_id, distance) - current_distance when distance < current_distance -> Map.put(acc, step_id, distance) - _ -> acc - end - end - - defp aggregator_step?(step_map, step_id) do - case Map.get(step_map, step_id) do - %{type_id: "aggregator"} -> true - _ -> false - end - end - - # =========================================================================== - # Public helpers for aggregation (called from closures via __MODULE__) - # =========================================================================== - - @doc false - def normalize_aggregator_input(nil), do: [] - - def normalize_aggregator_input(input) when is_list(input) do - input - |> List.flatten() - |> Enum.reject(&is_nil/1) - end - - def normalize_aggregator_input(input), do: [input] - - @doc false - def apply_aggregation("sum", items) do - items - |> Enum.map(&to_number/1) - |> Enum.sum() - end - - def apply_aggregation("count", items), do: length(items) - - def apply_aggregation("concat", items) do - Enum.map_join(items, "", &to_string/1) - end - - def apply_aggregation("first", []), do: nil - def apply_aggregation("first", items), do: List.first(items) - - def apply_aggregation("last", []), do: nil - def apply_aggregation("last", items), do: List.last(items) - - def apply_aggregation("min", []), do: nil - def apply_aggregation("min", items), do: Enum.min(items) - - def apply_aggregation("max", []), do: nil - def apply_aggregation("max", items), do: Enum.max(items) - - # Default "collect" - return flattened list - def apply_aggregation(_, items), do: items - - @doc false - def to_number(n) when is_number(n), do: n - - def to_number(s) when is_binary(s) do - case Float.parse(s) do - {f, _} -> f - :error -> 0 - end - end - - def to_number(_), do: 0 - - # Normalize an item that might be a single value or a list (from a Join) - @doc false - def normalize_item(nil), do: [] - - def normalize_item(item) when is_list(item) do - item |> List.flatten() |> Enum.reject(&is_nil/1) - end - - def normalize_item(item), do: [item] - - # =========================================================================== - # Private: Workflow Building - # =========================================================================== - - defp add_group_to_workflow(group, steps, connections, workflow, parent_lookup, step_opts) do - group_step_ids = MapSet.new(group.step_ids || []) - - group_steps = - Enum.filter(steps, fn step -> - MapSet.member?(group_step_ids, step.id) - end) - - group_connections = - Enum.filter(connections, fn conn -> - source_id = connection_field(conn, :source_step_id) - target_id = connection_field(conn, :target_step_id) - - MapSet.member?(group_step_ids, source_id) and - MapSet.member?(group_step_ids, target_id) - end) - - group_component = - build_group_component(group, group_steps, group_connections, step_opts) - - group_parents = Map.get(parent_lookup, group.id, []) - connect_component(workflow, group_component, group_parents, step_opts) - end - - defp build_group_component(group, group_steps, group_connections, step_opts) do - runic_step = - Runic.step( - fn input -> - execute_group(group, group_steps, group_connections, input, step_opts) - end, - name: group.id - ) - - unique_hash = :erlang.phash2({runic_step.hash, group.id}, 4_294_967_296) - %{runic_step | hash: unique_hash} - end - - defp execute_group(group, group_steps, group_connections, input, parent_opts) do - outer_outputs = Process.get(:fizz_accumulated_outputs, %{}) - outer_step_outputs = Process.get(:fizz_step_outputs, %{}) - outer_step_skipped = Process.get(:fizz_step_skipped) - outer_fan_out_context = Process.get(:fizz_fan_out_context) - - Process.put(:fizz_accumulated_outputs, %{}) - Process.put(:fizz_step_outputs, %{}) - Process.delete(:fizz_step_skipped) - Process.delete(:fizz_fan_out_context) - - pinned_outputs = Keyword.get(parent_opts, :step_outputs, %{}) - group_step_ids = MapSet.new(group.step_ids || []) - - group_pinned_outputs = - pinned_outputs - |> Enum.filter(fn {step_id, _output} -> MapSet.member?(group_step_ids, step_id) end) - |> Map.new() - - group_opts = - parent_opts - |> Keyword.put(:step_outputs, group_pinned_outputs) - |> Keyword.put(:trigger_data, input) - |> Keyword.put(:current_group, group) - - group_workflow = - build_group_workflow(group, group_steps, group_connections, group_opts) - |> Observability.attach_all_hooks( - execution_id: Keyword.get(parent_opts, :execution_id), - workflow_id: Keyword.get(parent_opts, :workflow_id), - skip_production_init: true - ) - - result_workflow = Workflow.react_until_satisfied(group_workflow, input) - output = extract_group_output(result_workflow, group.output_step_id) - - updated_outputs = Map.put(outer_outputs, group.id, output) - Process.put(:fizz_accumulated_outputs, updated_outputs) - Process.put(:fizz_step_outputs, outer_step_outputs) - - if is_nil(outer_step_skipped) do - Process.delete(:fizz_step_skipped) - else - Process.put(:fizz_step_skipped, outer_step_skipped) - end - - if is_nil(outer_fan_out_context) do - Process.delete(:fizz_fan_out_context) - else - Process.put(:fizz_fan_out_context, outer_fan_out_context) - end - - output - end - - defp build_group_workflow(group, steps, connections, opts) do - graph = Fizz.Graph.from_workflow!(steps, connections, validate: false) - upstream_lookup = build_upstream_lookup(graph) - step_map = Map.new(steps, &{&1.id, &1}) - slot_bindings = build_slot_binding_lookup(connections) - primary_parent_lookup = build_primary_parent_lookup(connections) - - step_opts = - opts - |> Keyword.put(:upstream_lookup, upstream_lookup) - |> Keyword.put(:slot_bindings, slot_bindings) - |> Keyword.put(:primary_parent_lookup, primary_parent_lookup) - |> Keyword.put(:all_groups, []) - - wrk = Workflow.new(name: "group_#{group.id}") - parent_lookup = build_parent_lookup(connections) - sorted_steps = topological_sort_steps(steps, connections) - - splitter_ids = - steps - |> Enum.filter(&(&1.type_id == "splitter")) - |> Enum.map(& &1.id) - |> MapSet.new() - - fan_out_path_lookup = build_fan_out_path_lookup(graph, splitter_ids, step_map) - - step_opts = - step_opts - |> Keyword.put(:splitter_ids, splitter_ids) - |> Keyword.put(:fan_out_path_lookup, fan_out_path_lookup) - - wrk = - Enum.reduce(sorted_steps, wrk, fn step, acc -> - add_step_to_workflow(step, acc, parent_lookup, step_opts) - end) - - wrk - |> put_step_metadata(steps) - |> track_all_fan_out_paths() - end - - defp extract_group_output(workflow, output_step_id) do - ctx = ExecutionContext.from_runic_workflow(workflow) - ExecutionContext.get_output(ctx, output_step_id) - end - - defp add_step_to_workflow(step, workflow, parent_lookup, step_opts) do - component_name = step.id - - parent_ids = - parent_lookup - |> Map.get(step.id, []) - |> Enum.uniq() - - # Determine if this aggregator should use fan-out or join semantics - component = - case fetch_pinned_output(step_opts, step.id) do - {:ok, pinned_output} -> - create_pinned_component(step, pinned_output) - - :error -> - if step.type_id == "aggregator" do - fan_out_path_lookup = Keyword.get(step_opts, :fan_out_path_lookup, %{}) - - if Map.has_key?(fan_out_path_lookup, step.id) do - # Active fan-out context: use Runic.reduce to accumulate all items. - create_fanout_aggregator(step, component_name) - else - # Outside fan-out path: use regular step that handles list input from join. - create_join_aggregator(step, component_name) - end - else - create_component(step, component_name, step_opts) - end - end - - workflow = connect_component(workflow, component, parent_ids, step_opts) - maybe_connect_fan_in(workflow, component) - end - - defp connect_component(workflow, component, [], _step_opts) do - Workflow.add(workflow, component) - end - - defp connect_component(workflow, component, [parent_id], _step_opts) do - Workflow.add(workflow, component, to: parent_id) - end - - defp connect_component(workflow, component, parent_ids, step_opts) do - fan_out_path_lookup = Keyword.get(step_opts, :fan_out_path_lookup, %{}) - - {workflow, join} = ensure_join(workflow, parent_ids, fan_out_path_lookup) - Workflow.add(workflow, component, to: join) - end - - defp maybe_connect_fan_in(workflow, %Runic.Workflow.Reduce{fan_in: fan_in}) do - case find_upstream_fan_out(workflow, fan_in) do - nil -> - workflow - - fan_out -> - workflow - |> Workflow.draw_connection(fan_out, fan_in, :fan_in) - |> track_mapped_path(fan_out, fan_in) - end - end - - defp maybe_connect_fan_in(workflow, _component), do: workflow - - defp fetch_pinned_output(step_opts, step_id) do - pinned_outputs = Keyword.get(step_opts, :step_outputs, %{}) - - if Map.has_key?(pinned_outputs, step_id) do - {:ok, Map.get(pinned_outputs, step_id)} - else - :error - end - end - - defp create_pinned_component(step, output) do - runic_step = Runic.step(fn _input -> output end, name: step.id) - unique_hash = :erlang.phash2({runic_step.hash, step.id}, 4_294_967_296) - %{runic_step | hash: unique_hash} - end - - defp track_mapped_path(workflow, fan_out, fan_in) do - path = workflow.graph |> Graph.get_shortest_path(fan_out, fan_in) - - path_hashes = - Enum.reduce(path || [], MapSet.new(), fn node, mapset -> - case node do - %{hash: hash} -> MapSet.put(mapset, hash) - _ -> mapset - end - end) - - %Workflow{ - workflow - | mapped: - Map.put( - workflow.mapped, - :mapped_paths, - MapSet.union(workflow.mapped[:mapped_paths] || MapSet.new(), path_hashes) - ) - } - end - - defp find_upstream_fan_out(workflow, fan_in) do - do_find_upstream_fan_out(workflow.graph, [fan_in], MapSet.new()) - end - - defp do_find_upstream_fan_out(_graph, [], _visited), do: nil - - defp do_find_upstream_fan_out(graph, [current | rest], visited) do - if MapSet.member?(visited, current) do - do_find_upstream_fan_out(graph, rest, visited) - else - visited = MapSet.put(visited, current) - - case current do - %FanOut{} -> - current - - _ -> - predecessors = - graph - |> Graph.in_edges(current) - |> Enum.filter(&(&1.label == :flow)) - |> Enum.map(& &1.v1) - - do_find_upstream_fan_out(graph, predecessors ++ rest, visited) - end - end - end - - # =========================================================================== - # Fan-out Path Tracking (for observability - item_index/items_total) - # =========================================================================== - - @doc false - defp track_all_fan_out_paths(workflow) do - fan_outs = - workflow.graph - |> Graph.vertices() - |> Enum.filter(&match?(%FanOut{}, &1)) - - Enum.reduce(fan_outs, workflow, fn fan_out, wf -> - downstream_hashes = find_all_downstream_hashes(wf.graph, fan_out) - - existing_paths = wf.mapped[:mapped_paths] || MapSet.new() - new_paths = MapSet.union(existing_paths, downstream_hashes) - - %Workflow{wf | mapped: Map.put(wf.mapped, :mapped_paths, new_paths)} - end) - end - - @doc false - defp find_all_downstream_hashes(graph, start_node) do - do_find_downstream_hashes(graph, [start_node], MapSet.new(), MapSet.new()) - end - - defp do_find_downstream_hashes(_graph, [], _visited, acc), do: acc - - defp do_find_downstream_hashes(graph, [current | rest], visited, acc) do - if MapSet.member?(visited, current) do - do_find_downstream_hashes(graph, rest, visited, acc) - else - visited = MapSet.put(visited, current) - - acc = - case current do - %{hash: hash} when not is_nil(hash) -> - MapSet.put(acc, hash) - - _ -> - acc - end - - successors = - case current do - %Runic.Workflow.FanIn{} -> - [] - - %Runic.Workflow.Reduce{} -> - [] - - _ -> - graph - |> Graph.out_edges(current) - |> Enum.filter(&(&1.label == :flow)) - |> Enum.map(& &1.v2) - end - - do_find_downstream_hashes(graph, successors ++ rest, visited, acc) - end - end - - # =========================================================================== - # Join Creation - # =========================================================================== - - defp ensure_join(%Workflow{} = workflow, parent_ids, fan_out_path_lookup) - when is_list(parent_ids) do - parent_steps = Enum.map(parent_ids, &Workflow.get_component!(workflow, &1)) - parent_hashes = Enum.map(parent_steps, &Component.hash/1) - - # Build fan_out_sources map for fan-out aware joining - fan_out_sources = - parent_ids - |> Enum.zip(parent_hashes) - |> Enum.reduce(%{}, fn {parent_id, parent_hash}, acc -> - case Map.get(fan_out_path_lookup, parent_id) do - nil -> - acc - - fan_out_id -> - # Get the hash of the fan-out component - case Workflow.get_component(workflow, fan_out_id) do - nil -> - acc - - fan_out_component -> - Map.put(acc, parent_hash, Component.hash(fan_out_component)) - end - end - end) - - # Determine if this is a fan-out aware join - join = - if map_size(fan_out_sources) > 0 do - Workflow.Join.new(parent_hashes, - fan_out_sources: fan_out_sources, - mode: :zip_nil - ) - else - Workflow.Join.new(parent_hashes) - end - - case Map.get(workflow.graph.vertices, join.hash) do - %Workflow.Join{} = existing_join -> {workflow, existing_join} - nil -> {Workflow.add_step(workflow, parent_steps, join), join} - end - end - - defp extract_source_id(source) do - Map.get(source, :id) || Map.get(source, :workflow_id) || "unknown" - end - - defp build_parent_lookup(connections) do - Enum.group_by( - connections, - &connection_field(&1, :target_step_id), - &connection_field(&1, :source_step_id) - ) - end - - defp build_primary_parent_lookup(connections) do - connections - |> Enum.filter(fn conn -> - conn - |> connection_field(:target_input) - |> main_target_input?() - end) - |> Enum.group_by( - &connection_field(&1, :target_step_id), - &connection_field(&1, :source_step_id) - ) - end - - defp build_slot_binding_lookup(connections) do - connections - |> Enum.reduce(%{}, fn conn, acc -> - target_input = connection_field(conn, :target_input) - - if main_target_input?(target_input) do - acc - else - target_step_id = connection_field(conn, :target_step_id) - source_step_id = connection_field(conn, :source_step_id) - slot_id = normalize_target_input(target_input) - - Map.update(acc, target_step_id, %{slot_id => [source_step_id]}, fn slot_bindings -> - Map.update(slot_bindings, slot_id, [source_step_id], fn existing -> - existing ++ [source_step_id] - end) - end) - end - end) - end - - defp normalize_target_input(target_input) when is_binary(target_input), do: target_input - - defp normalize_target_input(target_input) when is_atom(target_input), - do: Atom.to_string(target_input) - - defp normalize_target_input(_target_input), do: "main" - - defp main_target_input?(target_input) when target_input in [nil, "main", :main, ""], do: true - defp main_target_input?(_target_input), do: false - - defp build_upstream_lookup(graph) do - Enum.reduce(Fizz.Graph.vertex_ids(graph), %{}, fn step_id, acc -> - Map.put(acc, step_id, Fizz.Graph.upstream(graph, step_id)) - end) - end - - defp topological_sort_steps(steps, connections) do - step_ids = Enum.map(steps, & &1.id) - step_map = Map.new(steps, &{&1.id, &1}) - - in_degrees = Map.new(step_ids, &{&1, 0}) - - in_degrees = - Enum.reduce(connections, in_degrees, fn conn, acc -> - Map.update(acc, connection_field(conn, :target_step_id), 0, &(&1 + 1)) - end) - - adjacency = - Enum.reduce(connections, %{}, fn conn, acc -> - source_step_id = connection_field(conn, :source_step_id) - target_step_id = connection_field(conn, :target_step_id) - Map.update(acc, source_step_id, [target_step_id], &[target_step_id | &1]) - end) - - roots = Enum.filter(step_ids, &(Map.get(in_degrees, &1) == 0)) - sorted_ids = do_kahn_sort(roots, in_degrees, adjacency, []) - Enum.map(sorted_ids, &Map.get(step_map, &1)) - end - - defp do_kahn_sort([], _in_degrees, _adjacency, sorted), do: Enum.reverse(sorted) - - defp do_kahn_sort([id | rest], in_degrees, adjacency, sorted) do - children = Map.get(adjacency, id, []) - - {new_rest, new_in_degrees} = - Enum.reduce(children, {rest, in_degrees}, fn child, {r, degs} -> - new_deg = Map.get(degs, child) - 1 - degs = Map.put(degs, child, new_deg) - if new_deg == 0, do: {r ++ [child], degs}, else: {r, degs} - end) - - do_kahn_sort(new_rest, new_in_degrees, adjacency, [id | sorted]) - end - - defp put_step_metadata(workflow, steps, groups \\ []) - - defp put_step_metadata(workflow, steps, groups) when is_list(steps) do - step_metadata = - Enum.reduce(steps, %{}, fn step, acc -> - Map.put(acc, step.id, %{ - type_id: step.type_id, - step_id: step.id, - name: step.name - }) - end) - - step_metadata = - Enum.reduce(groups, step_metadata, fn group, acc -> - Map.put(acc, group.id, %{ - type_id: "node_group", - step_id: group.id, - name: group.name || group.id, - skip_observability: true - }) - end) - - Map.put(workflow, :__step_metadata__, step_metadata) - end - - defp put_step_metadata(workflow, _steps, _groups), do: workflow - - # =========================================================================== - # Private: Component Creation - # =========================================================================== - - defp create_splitter(step, component_name, opts) do - work_fn = fn input -> - result = StepRunner.execute_with_context(step, input, opts) - # The FanOut invokable sets item_index and items_total on each Fact - # No need for global process state tracking - result - end - - %FanOut{ - name: component_name, - work: work_fn, - hash: :erlang.phash2({:fan_out, step.id}, 4_294_967_296) - } - end - - # Fan-out aggregator: uses Runic.reduce for proper FanIn/FanOut semantics - defp create_fanout_aggregator(step, component_name) do - operation = Map.get(step.config, "operation", "collect") - name = component_name - step_id = step.id - - reduce = - case operation do - "sum" -> - Runic.reduce( - 0, - fn item, acc -> - items = __MODULE__.normalize_item(item) - Enum.reduce(items, acc, fn i, a -> a + (__MODULE__.to_number(i) || 0) end) - end, - name: name - ) - - "count" -> - Runic.reduce( - 0, - fn item, acc -> - items = __MODULE__.normalize_item(item) - acc + length(items) - end, - name: name - ) - - "concat" -> - Runic.reduce( - "", - fn item, acc -> - items = __MODULE__.normalize_item(item) - acc <> Enum.map_join(items, "", &to_string/1) - end, - name: name - ) - - "first" -> - Runic.reduce( - nil, - fn - item, nil -> - items = __MODULE__.normalize_item(item) - List.first(items) - - _item, acc -> - acc - end, - name: name - ) - - "last" -> - Runic.reduce( - nil, - fn item, _acc -> - items = __MODULE__.normalize_item(item) - List.last(items) || List.first(items) - end, - name: name - ) - - "min" -> - Runic.reduce( - nil, - fn item, acc -> - items = __MODULE__.normalize_item(item) - item_min = Enum.min(items, fn -> nil end) - - case {acc, item_min} do - {nil, val} -> val - {val, nil} -> val - {a, b} -> min(a, b) - end - end, - name: name - ) - - "max" -> - Runic.reduce( - nil, - fn item, acc -> - items = __MODULE__.normalize_item(item) - item_max = Enum.max(items, fn -> nil end) - - case {acc, item_max} do - {nil, val} -> val - {val, nil} -> val - {a, b} -> max(a, b) - end - end, - name: name - ) - - # Default "collect" - accumulate all items into a flat list - _ -> - Runic.reduce( - [], - fn item, acc -> - items = __MODULE__.normalize_item(item) - acc ++ items - end, - name: name - ) - end - - unique_hash = :erlang.phash2({reduce.hash, step_id}, 4_294_967_296) - unique_fan_in = %{reduce.fan_in | hash: unique_hash} - %{reduce | hash: unique_hash, fan_in: unique_fan_in} - end - - # Join aggregator: handles pre-combined list input from Join nodes (no fan-out upstream) - defp create_join_aggregator(step, component_name) do - operation = Map.get(step.config, "operation", "collect") - step_id = step.id - - runic_step = - Runic.step( - fn input -> - items = __MODULE__.normalize_aggregator_input(input) - __MODULE__.apply_aggregation(operation, items) - end, - name: component_name - ) - - unique_hash = :erlang.phash2({runic_step.hash, step_id}, 4_294_967_296) - %{runic_step | hash: unique_hash} - end - - # Explicit join step: user-configurable join behavior - defp create_explicit_join(step, component_name, _opts) do - mode = Map.get(step.config, "mode", "zip_nil") - flatten? = Map.get(step.config, "flatten", false) - - runic_step = - Runic.step( - fn input -> - result = apply_join_mode(mode, input) - - if flatten? do - List.flatten(result) - else - result - end - end, - name: component_name - ) - - unique_hash = :erlang.phash2({runic_step.hash, step.id}, 4_294_967_296) - %{runic_step | hash: unique_hash} - end - - defp apply_join_mode("wait_all", input) when is_list(input) do - List.flatten(input, []) - end - - defp apply_join_mode("zip_nil", input) when is_list(input) do - Runic.Workflow.Join.apply_mode(:zip_nil, normalize_branch_input(input)) - end - - defp apply_join_mode("zip_shortest", input) when is_list(input) do - Runic.Workflow.Join.apply_mode(:zip_shortest, normalize_branch_input(input)) - end - - defp apply_join_mode("zip_cycle", input) when is_list(input) do - Runic.Workflow.Join.apply_mode(:zip_cycle, normalize_branch_input(input)) - end - - defp apply_join_mode("cartesian", input) when is_list(input) do - Runic.Workflow.Join.apply_mode(:cartesian, normalize_branch_input(input)) - end - - defp apply_join_mode(_mode, input), do: input - - defp normalize_branch_input(input) when is_list(input) do - if Enum.all?(input, &is_list/1) do - input - else - [input] - end - end - - defp create_condition(step, component_name, opts) do - condition_expr = Map.get(step.config, "condition", "true") - - Runic.rule( - name: component_name, - if: fn input -> evaluate_condition(condition_expr, input, opts) end, - do: fn input -> input end - ) - end - - defp create_switch(step, _component_name, opts) do - StepRunner.create(step, opts) - end - - defp evaluate_condition(expr, input, opts) when is_binary(expr) do - vars = %{ - "json" => input, - "variables" => Keyword.get(opts, :variables, %{}) - } - - case Fizz.Runtime.Expression.evaluate_with_vars(expr, vars) do - {:ok, "true"} -> true - {:ok, "false"} -> false - {:ok, result} when is_binary(result) -> result != "" and result != "0" - {:ok, result} -> !!result - {:error, _} -> false - end - end - - defp evaluate_condition(_, _, _), do: true -end diff --git a/lib/fizz/runtime/step_execution_state.ex b/lib/fizz/runtime/step_execution_state.ex deleted file mode 100644 index 9413b20..0000000 --- a/lib/fizz/runtime/step_execution_state.ex +++ /dev/null @@ -1,76 +0,0 @@ -defmodule Fizz.Runtime.StepExecutionState do - @moduledoc """ - Unified state management for step executions. - Ensures consistent serialization for both persistence and broadcasting. - """ - - alias Fizz.Runtime.Serializer - - def build(execution_id, step_id, status, opts \\ []) do - %{ - execution_id: execution_id, - step_id: step_id, - status: status, - input_data: opts[:input_data] |> Serializer.wrap_for_db(), - output_data: opts[:output_data] |> Serializer.wrap_for_db(), - output_item_count: opts[:output_item_count], - item_index: opts[:item_index], - items_total: opts[:items_total], - error: opts[:error], - step_type_id: opts[:step_type_id], - duration_us: opts[:duration_us], - started_at: opts[:started_at], - completed_at: opts[:completed_at], - metadata: opts[:metadata] || %{} - } - end - - def started(execution_id, step_id, input_data, opts \\ []) do - build(execution_id, step_id, :running, - input_data: input_data, - step_type_id: opts[:step_type_id], - item_index: opts[:item_index], - items_total: opts[:items_total], - started_at: opts[:started_at] || DateTime.utc_now() - ) - end - - def completed(execution_id, step_id, input_data, output_data, opts \\ []) do - build(execution_id, step_id, :completed, - input_data: input_data, - output_data: output_data, - output_item_count: opts[:output_item_count], - item_index: opts[:item_index], - items_total: opts[:items_total], - step_type_id: opts[:step_type_id], - duration_us: opts[:duration_us], - started_at: opts[:started_at], - completed_at: opts[:completed_at] || DateTime.utc_now() - ) - end - - def skipped(execution_id, step_id, input_data, opts \\ []) do - build(execution_id, step_id, :skipped, - input_data: input_data, - step_type_id: opts[:step_type_id], - item_index: opts[:item_index], - items_total: opts[:items_total], - duration_us: opts[:duration_us], - started_at: opts[:started_at], - completed_at: opts[:completed_at] || DateTime.utc_now() - ) - end - - def failed(execution_id, step_id, input_data, error, opts \\ []) do - build(execution_id, step_id, :failed, - input_data: input_data, - error: error, - step_type_id: opts[:step_type_id], - item_index: opts[:item_index], - items_total: opts[:items_total], - duration_us: opts[:duration_us], - started_at: opts[:started_at], - completed_at: opts[:completed_at] || DateTime.utc_now() - ) - end -end diff --git a/lib/fizz/runtime/steps/step_runner.ex b/lib/fizz/runtime/steps/step_runner.ex deleted file mode 100644 index a08155d..0000000 --- a/lib/fizz/runtime/steps/step_runner.ex +++ /dev/null @@ -1,325 +0,0 @@ -defmodule Fizz.Runtime.Steps.StepRunner do - @moduledoc """ - Creates Runic Steps from Fizz workflow steps. - - This module bridges Fizz's step system with Runic's step-based execution. - It handles: - - Expression evaluation in step configs before execution - - Building execution context from Runic workflow state - - Error handling with controlled throws for proper error propagation - - ## Usage - - step = %Fizz.Workflows.Embeds.Step{id: "step_1", type_id: "debug", config: %{}} - step = StepRunner.create(step, execution_id: "exec_123") - - # The step can then be added to a Runic workflow - Workflow.add(workflow, step) - """ - - require Runic - alias Fizz.Accounts.Scope - alias Fizz.Runtime.ExecutionContext - alias Fizz.Runtime.Expression - alias Fizz.Steps.Executors.Behaviour, as: ExecutorBehaviour - - @type workflow_step :: Fizz.Workflows.Embeds.Step.t() - @type step_opts :: [ - execution_id: String.t(), - workflow_id: String.t(), - variables: map(), - metadata: map(), - scope: Scope.t() | nil, - step_outputs: map(), - slot_bindings: map(), - primary_parent_lookup: map(), - trigger_data: map(), - trigger_type: atom(), - current_group: map(), - all_groups: [map()] - ] - - @doc """ - Creates a Runic step from an Fizz step. - - The step wraps the step's executor and handles: - - Building context from the Runic fact input - - Evaluating expressions in the step's config - - Executing the step via its registered executor - - Error handling with controlled throws - - ## Options - - - `:execution_id` - The Fizz Execution record ID - - `:workflow_id` - The source workflow ID - - `:variables` - Workflow-level variables for expression evaluation - - `:metadata` - Execution metadata - - `:scope` - Authenticated caller scope for execution-time integrations - """ - @spec create(workflow_step(), step_opts()) :: Runic.Workflow.Step.t() - def create(step, opts \\ []) do - runic_step = - Runic.step( - fn input -> execute_step(step, input, opts) end, - name: runic_name(step) - ) - - # Ensure unique hash for programmatic steps to avoid graph vertex collisions - # We use phash2 on the combination of the original hash and the step id - unique_hash = :erlang.phash2({runic_step.hash, step.id}, 4_294_967_296) - %{runic_step | hash: unique_hash} - end - - defp execute_step(step, input, opts) do - case run_step(step, input, opts) do - {:ok, result} -> - result - - {:error, {:throw, reason}} -> - throw(reason) - - {:error, reason} -> - throw({:step_error, step.id, {:execution_error, reason}}) - end - end - - defp run_step(step, input, opts) do - try do - {:ok, execute_with_context(step, input, opts)} - rescue - e -> - {:error, e} - catch - kind, reason -> - {:error, {kind, reason}} - end - end - - # Mapping of trigger step types to their corresponding trigger_type atoms - @trigger_type_mapping %{ - "manual_input" => :manual, - "webhook_trigger" => :webhook, - "schedule_trigger" => :schedule, - "event_trigger" => :event - } - - @doc """ - Executes a step with full context building and expression evaluation. - - This is the core execution logic that: - 1. Builds an ExecutionContext from the input - 2. Evaluates expressions in the step's config - 3. Calls the step's executor - 4. Handles errors appropriately - """ - @spec execute_with_context(workflow_step(), term(), step_opts()) :: term() - def execute_with_context(step, input, opts) do - # Build context from options and input - ctx = build_context(step, input, opts) - executor_input = build_executor_input(step, ctx.input, ctx.step_outputs, opts) - - # Check if this is a non-active trigger that should be skipped - if should_skip_trigger?(step.type_id, ctx.trigger_type) do - Process.put(:fizz_step_skipped, true) - # Return nil to avoid propagating input from skipped triggers (prevents duplicates in joins) - nil - else - do_execute(step, executor_input, ctx) - end - end - - defp should_skip_trigger?(step_type_id, current_trigger_type) do - case Map.get(@trigger_type_mapping, step_type_id) do - # Not a trigger step, don't skip - nil -> false - expected_type -> expected_type != current_trigger_type - end - end - - defp do_execute(step, input, ctx) do - evaluated_config = - case evaluate_config(step.config, ctx) do - {:ok, config} -> - if ctx.execution_id do - Task.start(fn -> - Fizz.Executions.update_step_execution_metadata(ctx.execution_id, ctx.step_id, %{ - "evaluated_config" => config - }) - end) - end - - config - - {:error, reason} -> - throw({:step_error, step.id, {:expression_error, reason}}) - end - - # Resolve and execute the step - executor = ExecutorBehaviour.resolve!(step.type_id) - - case executor.execute(evaluated_config, input, ctx) do - {:ok, result} -> - result - - {:error, reason} -> - throw({:step_error, step.id, reason}) - - {:skip, _reason} -> - # Signal observability hook that this step was skipped - Process.put(:fizz_step_skipped, true) - # Skip produces nil, which Runic will handle appropriately - nil - end - end - - # =========================================================================== - # Private Helpers - # =========================================================================== - - defp build_context(step, input, opts) do - # Get pinned outputs from opts (static, from workflow start) - pinned_outputs = Keyword.get(opts, :step_outputs, %{}) - - # Get dynamic outputs from process dictionary (set by before_step_context hook) - # This contains outputs from steps that completed earlier in this execution - dynamic_outputs = Process.get(:fizz_step_outputs, %{}) - - # Merge: dynamic outputs take precedence (they're fresh), then pinned - step_outputs = - pinned_outputs - |> Map.merge(dynamic_outputs) - |> maybe_filter_group_outputs(opts) - - # Filter outputs to only include upstream steps - upstream_ids = Map.get(Keyword.get(opts, :upstream_lookup, %{}), step.id, []) - - step_outputs = - Map.take(step_outputs, upstream_ids) - - primary_input = build_primary_input(step, input, step_outputs, opts) - - ExecutionContext.new( - execution_id: Keyword.get(opts, :execution_id), - workflow_id: Keyword.get(opts, :workflow_id), - step_id: step.id, - variables: Keyword.get(opts, :variables, %{}), - metadata: Keyword.get(opts, :metadata, %{}), - scope: Keyword.get(opts, :scope), - input: primary_input, - step_outputs: step_outputs, - trigger: Keyword.get(opts, :trigger_data, %{}), - trigger_type: Keyword.get(opts, :trigger_type) - ) - end - - defp build_primary_input(step, input, step_outputs, opts) do - primary_parents = - opts - |> Keyword.get(:primary_parent_lookup, %{}) - |> Map.get(step.id, []) - - slot_bindings = - opts - |> Keyword.get(:slot_bindings, %{}) - |> Map.get(step.id, %{}) - - case primary_parents do - [] -> - case map_size(slot_bindings) do - 0 -> input - _ -> nil - end - - [parent_step_id] -> - case map_size(slot_bindings) do - # For non-slotted steps, trust the current fact input so fan-out - # descendants receive each item (not a stale output from process state). - 0 -> - input - - _ -> - Map.get(step_outputs, parent_step_id, input) - end - - _multiple_parents -> - # Keep existing semantics for fan-in joins and multi-parent inputs. - input - end - end - - defp build_executor_input(step, primary_input, step_outputs, opts) do - case subnode_slots_for_step(step.type_id) do - [] -> - primary_input - - slot_defs -> - slot_bindings = - opts - |> Keyword.get(:slot_bindings, %{}) - |> Map.get(step.id, %{}) - - Enum.reduce(slot_defs, %{"_primary" => primary_input}, fn slot_def, acc -> - slot_id = slot_field(slot_def, :id) - input_key = slot_field(slot_def, :input_key) || slot_id - cardinality = slot_field(slot_def, :cardinality) || "one" - source_step_ids = Map.get(slot_bindings, slot_id, []) - - slot_value = - source_step_ids - |> Enum.map(&Map.get(step_outputs, &1)) - |> slot_value_for_cardinality(cardinality) - - Map.put(acc, input_key, slot_value) - end) - end - end - - defp slot_value_for_cardinality(values, "many"), do: values - defp slot_value_for_cardinality(values, _cardinality), do: List.first(values) - - defp subnode_slots_for_step(type_id) when is_binary(type_id) do - case Fizz.Steps.Registry.get(type_id) do - {:ok, type} when is_list(type.subnode_slots) -> - type.subnode_slots - - _ -> - [] - end - end - - defp slot_field(slot, key) when is_map(slot) and is_atom(key) do - Map.get(slot, key) || Map.get(slot, Atom.to_string(key)) - end - - defp maybe_filter_group_outputs(step_outputs, opts) do - case Keyword.get(opts, :current_group) do - nil -> - groups = Keyword.get(opts, :all_groups, []) - - hidden_step_ids = - groups - |> Enum.flat_map(& &1.step_ids) - |> MapSet.new() - - Map.reject(step_outputs, fn {step_id, _} -> - MapSet.member?(hidden_step_ids, step_id) - end) - - _group -> - step_outputs - end - end - - defp evaluate_config(config, ctx) when is_map(config) do - # Build variables for expression evaluation - vars = Expression.Context.build_from_context(ctx) - - Expression.evaluate_deep(config, vars) - end - - defp evaluate_config(config, _ctx), do: {:ok, config} - - # Step ID is now used directly as Runic name - defp runic_name(%{id: id}) when is_binary(id) and byte_size(id) > 0, do: id - defp runic_name(_), do: "step" -end diff --git a/lib/fizz/runtime/triggers/activator.ex b/lib/fizz/runtime/triggers/activator.ex deleted file mode 100644 index b0c1184..0000000 --- a/lib/fizz/runtime/triggers/activator.ex +++ /dev/null @@ -1,97 +0,0 @@ -defmodule Fizz.Runtime.Triggers.Activator do - @moduledoc """ - Activates and deactivates triggers for workflows. - Called when workflows are published, archived, or deleted. - """ - require Logger - alias Fizz.Runtime.Triggers.{Registry, ScheduleManager} - alias Fizz.Workflows.Workflow - - @doc """ - Activates all triggers for a workflow. - """ - def activate(%Workflow{} = workflow) do - Logger.info("Activating triggers for workflow: #{workflow.id}") - - # 1. Extract triggers from steps - workflow = Fizz.Repo.preload(workflow, [:published_version, :draft]) - steps = get_steps(workflow) - - webhooks = - steps - |> Enum.filter(&(&1.type_id == "webhook_trigger")) - |> Enum.map(fn step -> - path = Map.get(step.config, "path") || Map.get(step.config, :path) || step.id - - method = - Map.get(step.config, "http_method") || Map.get(step.config, :http_method) || - Map.get(step.config, "method") || Map.get(step.config, :method) || "POST" - - %{ - path: normalize_path(path), - method: normalize_method(method), - config: step.config - } - end) - - # 2. Register in the active registry - Registry.register(workflow.id, webhooks: webhooks) - - # 3. Activate specific triggers (schedules) - - Enum.each(steps, fn step -> - case step.type_id do - "schedule_trigger" -> - ScheduleManager.activate(workflow.id, step.config) - - "webhook_trigger" -> - # Already handled by Registry.register above - :ok - - _ -> - :ok - end - end) - end - - @doc """ - Deactivates all triggers for a workflow. - """ - def deactivate(workflow_id) do - Logger.info("Deactivating triggers for workflow: #{workflow_id}") - - # 1. Unregister from registry - Registry.unregister(workflow_id) - - # 2. Stop schedules - ScheduleManager.deactivate(workflow_id) - end - - defp normalize_path(nil), do: nil - - defp normalize_path(path) when is_binary(path) do - path - |> String.trim() - |> String.trim_leading("/") - |> String.trim_trailing("/") - |> case do - "" -> nil - trimmed -> trimmed - end - end - - defp normalize_method(nil), do: "POST" - - defp normalize_method(method) when is_binary(method) do - method - |> String.trim() - |> case do - "" -> "POST" - trimmed -> String.upcase(trimmed) - end - end - - defp get_steps(%{published_version: %{steps: steps}}) when not is_nil(steps), do: steps - defp get_steps(%{draft: %{steps: steps}}) when not is_nil(steps), do: steps - defp get_steps(_), do: [] -end diff --git a/lib/fizz/runtime/triggers/registry.ex b/lib/fizz/runtime/triggers/registry.ex deleted file mode 100644 index 1185a2d..0000000 --- a/lib/fizz/runtime/triggers/registry.ex +++ /dev/null @@ -1,173 +0,0 @@ -defmodule Fizz.Runtime.Triggers.Registry do - @moduledoc """ - GenServer that manages active triggers for published workflows. - - Responsibilities: - - Tracks which workflows are active and have triggers. - - Acts as a lookup for webhook routing (to verify workflow status). - """ - use GenServer - require Logger - - alias Fizz.Workflows - alias Fizz.Workflows.Workflow - alias Fizz.Repo - - @type state :: %{ - active_workflows: MapSet.t(), - webhook_routes: %{String.t() => %{workflow_id: Ecto.UUID.t(), config: map()}} - } - - # ============================================================================ - # API - # ============================================================================ - - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Registers a workflow for trigger activation. - Usually called when a workflow is published or toggled to 'active'. - """ - def register(workflow_id, opts \\ [], name \\ __MODULE__) do - GenServer.cast(name, {:register, workflow_id, opts}) - end - - @doc """ - Unregisters a workflow, stopping its child processes and removing it from active registry. - """ - def unregister(workflow_id, name \\ __MODULE__) do - GenServer.cast(name, {:unregister, workflow_id}) - end - - @doc """ - Checks if a workflow is currently active in the registry. - """ - def active?(workflow_id, name \\ __MODULE__) do - GenServer.call(name, {:is_active, workflow_id}) - end - - @doc """ - Looks up a webhook route in the registry. - Returns {:ok, %{workflow_id: id, config: config}} or :error. - """ - def lookup_webhook(path, method, name \\ __MODULE__) do - GenServer.call(name, {:lookup_webhook, path, method}) - end - - # ============================================================================ - # Callbacks - # ============================================================================ - - @impl true - def init(_opts) do - Logger.info("Initializing Trigger Registry") - - {:ok, %{active_workflows: MapSet.new(), webhook_routes: %{}}, - {:continue, :load_active_workflows}} - end - - @impl true - def handle_continue(:load_active_workflows, state) do - # Load all active workflows from DB and register them - active_ids = load_active_workflow_ids() - - Logger.info("Trigger Registry: Loaded #{MapSet.size(active_ids)} active workflows") - - # Activate all loaded workflows - active_ids - |> Enum.each(fn id -> - case Repo.get(Workflow, id) do - nil -> :ok - workflow -> Fizz.Runtime.Triggers.Activator.activate(workflow) - end - end) - - {:noreply, %{state | active_workflows: active_ids}} - end - - defp load_active_workflow_ids do - Workflows.list_active_workflows_query() - |> Repo.all() - |> Enum.map(& &1.id) - |> MapSet.new() - rescue - error in Postgrex.Error -> - if missing_workflows_table?(error) do - Logger.warning("Trigger Registry: workflows table missing, skipping trigger bootstrap") - MapSet.new() - else - reraise(error, __STACKTRACE__) - end - end - - defp missing_workflows_table?(%Postgrex.Error{postgres: %{code: :undefined_table}}), do: true - defp missing_workflows_table?(%Postgrex.Error{postgres: %{pg_code: "42P01"}}), do: true - defp missing_workflows_table?(_error), do: false - - @impl true - def handle_cast({:register, workflow_id, opts}, state) do - Logger.info("Registering triggers for workflow: #{workflow_id}") - - # Update active workflows - active_workflows = MapSet.put(state.active_workflows, workflow_id) - - # Update webhook routes if provided - webhook_routes = - case Keyword.get(opts, :webhooks) do - nil -> - state.webhook_routes - - webhooks -> - # First remove any old routes for this workflow (to avoid stale routes if config changed) - cleaned_routes = - state.webhook_routes - |> Enum.reject(fn {_key, val} -> val.workflow_id == workflow_id end) - |> Enum.into(%{}) - - # Add new routes - Enum.reduce(webhooks, cleaned_routes, fn %{path: path, method: method, config: config}, - acc -> - key = "#{method}:#{path}" - Map.put(acc, key, %{workflow_id: workflow_id, config: config}) - end) - end - - {:noreply, %{state | active_workflows: active_workflows, webhook_routes: webhook_routes}} - end - - @impl true - def handle_cast({:unregister, workflow_id}, state) do - Logger.info("Unregistering triggers for workflow: #{workflow_id}") - - active_workflows = MapSet.delete(state.active_workflows, workflow_id) - - webhook_routes = - state.webhook_routes - |> Enum.reject(fn {_key, val} -> val.workflow_id == workflow_id end) - |> Enum.into(%{}) - - {:noreply, %{state | active_workflows: active_workflows, webhook_routes: webhook_routes}} - end - - @impl true - def handle_call({:lookup_webhook, path, method}, _from, state) do - method = String.upcase(method) - key = "#{method}:#{path}" - - reply = - case Map.fetch(state.webhook_routes, key) do - {:ok, _} = ok -> ok - :error -> Map.fetch(state.webhook_routes, "ANY:#{path}") - end - - {:reply, reply, state} - end - - @impl true - def handle_call({:is_active, workflow_id}, _from, state) do - {:reply, MapSet.member?(state.active_workflows, workflow_id), state} - end -end diff --git a/lib/fizz/runtime/triggers/schedule_manager.ex b/lib/fizz/runtime/triggers/schedule_manager.ex deleted file mode 100644 index 44f0439..0000000 --- a/lib/fizz/runtime/triggers/schedule_manager.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule Fizz.Runtime.Triggers.ScheduleManager do - @moduledoc """ - Manages scheduled trigger jobs via Oban. - """ - require Logger - alias Fizz.Workers.ScheduledTriggerWorker - - @doc """ - Activates scheduling for a workflow. - Ensures there is exactly one pending job for the workflow's schedule. - """ - def activate(workflow_id, config) do - interval = Map.get(config, "interval_seconds") || Map.get(config, :interval_seconds) - - if interval do - Logger.info("Activating schedule for workflow #{workflow_id} with interval #{interval}s") - # Cancel existing to avoid duplicates if re-activated - deactivate(workflow_id) - - # Create initial job - # We schedule it for now, unless configured otherwise - ScheduledTriggerWorker.enqueue(workflow_id, config) - else - Logger.warning("Schedule trigger for workflow #{workflow_id} missing interval_seconds") - end - end - - @doc """ - Deactivates scheduling by cancelling pending jobs. - """ - def deactivate(workflow_id) do - import Ecto.Query - - # Cancel all pending/scheduled jobs for this workflow - Oban.Job - |> where(worker: ^to_string(ScheduledTriggerWorker)) - |> where([j], fragment("args->>'workflow_id' = ?", ^workflow_id)) - |> where([j], j.state in ["available", "scheduled", "retryable"]) - |> Oban.cancel_all_jobs() - end -end diff --git a/lib/fizz/runtime/serializer.ex b/lib/fizz/serializer.ex similarity index 95% rename from lib/fizz/runtime/serializer.ex rename to lib/fizz/serializer.ex index 7ed33f6..aa75cde 100644 --- a/lib/fizz/runtime/serializer.ex +++ b/lib/fizz/serializer.ex @@ -1,6 +1,6 @@ -defmodule Fizz.Runtime.Serializer do +defmodule Fizz.Serializer do @moduledoc """ - Shared helpers for converting runtime values into JSON-safe terms. + Shared helpers for converting values into JSON-safe terms. """ @type atom_mode :: :string | :preserve_boolean_and_nil diff --git a/lib/fizz/steps/executors/ai_agent.ex b/lib/fizz/steps/executors/ai_agent.ex index a664020..8c9f363 100644 --- a/lib/fizz/steps/executors/ai_agent.ex +++ b/lib/fizz/steps/executors/ai_agent.ex @@ -2,7 +2,7 @@ defmodule Fizz.Steps.Executors.AIAgent do @moduledoc """ Root AI agent node that consumes typed sub-node slots. - Sub-node outputs are injected by `Fizz.Runtime.Steps.StepRunner` under: + Sub-node outputs are injected by the workflow runtime under: - `_primary` - Primary flow input - `model` - Output from `openai_model` or `anthropic_model` @@ -21,7 +21,6 @@ defmodule Fizz.Steps.Executors.AIAgent do alias Fizz.Accounts.Scope alias Fizz.Integrations.Providers.OpenAIApiKey - alias Fizz.Runtime.ExecutionContext @behaviour Fizz.Steps.Executors.Behaviour @@ -91,7 +90,7 @@ defmodule Fizz.Steps.Executors.AIAgent do def default_config, do: @default_config @impl true - def execute(config, input, %ExecutionContext{} = ctx) do + def execute(config, input, ctx) when is_map(ctx) do with {:ok, model_config} <- normalize_model_config(slot_value(input, "model")), {:ok, messages} <- normalize_messages(slot_value(input, "prompt"), slot_value(input, "_primary")), @@ -107,7 +106,7 @@ defmodule Fizz.Steps.Executors.AIAgent do end @impl true - def execute(config, input, _ctx), do: execute(config, input, %ExecutionContext{}) + def execute(config, input, _ctx), do: execute(config, input, %{}) @impl true def validate_config(config) do @@ -264,7 +263,7 @@ defmodule Fizz.Steps.Executors.AIAgent do defp slot_key_atom("tools"), do: :tools defp slot_key_atom(_key), do: nil - defp scope_and_organization(%ExecutionContext{} = ctx) do + defp scope_and_organization(ctx) when is_map(ctx) do with {:ok, scope} <- scope_from_context(ctx), {:ok, organization_id} <- organization_from_scope(scope) do {:ok, scope, organization_id} @@ -273,16 +272,21 @@ defmodule Fizz.Steps.Executors.AIAgent do defp scope_and_organization(_), do: {:error, :scope_not_available} - defp scope_from_context(%ExecutionContext{scope: %Scope{} = scope}), do: {:ok, scope} + defp scope_from_context(ctx) when is_map(ctx) do + metadata = Map.get(ctx, :metadata) || Map.get(ctx, "metadata") - defp scope_from_context(%ExecutionContext{metadata: metadata}) when is_map(metadata) do - case Map.get(metadata, :scope) || Map.get(metadata, "scope") do + scope = + Map.get(ctx, :scope) || + Map.get(ctx, "scope") || + if(is_map(metadata), do: Map.get(metadata, :scope) || Map.get(metadata, "scope")) + + case scope do %Scope{} = scope -> {:ok, scope} _ -> {:error, :scope_not_available} end end - defp scope_from_context(%ExecutionContext{}), do: {:error, :scope_not_available} + defp scope_from_context(_), do: {:error, :scope_not_available} defp organization_from_scope(%Scope{organization_id: organization_id} = _scope) when is_binary(organization_id) and byte_size(organization_id) > 0, diff --git a/lib/fizz/steps/executors/respond_to_webhook.ex b/lib/fizz/steps/executors/respond_to_webhook.ex deleted file mode 100644 index 0c74648..0000000 --- a/lib/fizz/steps/executors/respond_to_webhook.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule Fizz.Steps.Executors.RespondToWebhook do - @moduledoc """ - Node that sends a response back to the webhook caller. - - This is used when the Webhook Trigger is set to `on_respond_node`. - """ - use Fizz.Steps.Definition, - id: "respond_to_webhook", - name: "Respond to Webhook", - category: "Communication", - description: "Sends a response back to the incoming webhook request", - icon: "hero-reply", - kind: :action - - @config_schema %{ - "type" => "object", - "properties" => %{ - "status" => %{ - "type" => "integer", - "title" => "Status Code", - "default" => 200 - }, - "body" => %{ - "type" => "string", - "title" => "Response Body", - "description" => "The data to return (can be an expression)" - }, - "content_type" => %{ - "type" => "string", - "title" => "Content Type", - "default" => "application/json" - } - } - } - - @behaviour Fizz.Steps.Executors.Behaviour - - @impl true - def execute(config, input, context) do - # 1. Resolve body expression if it's dynamic - body = Map.get(config, "body", input) - status = Map.get(config, "status", 200) - content_type = Map.get(config, "content_type", "application/json") - - # 2. Extract handler PID from context metadata - # We'll need to ensure the runtime passes this along - case get_handler_pid(context) do - pid when is_pid(pid) -> - send(pid, {:webhook_response, %{status: status, body: body, content_type: content_type}}) - {:ok, input} - - nil -> - # Log warning if no handler found, but continue (maybe it's a test or async run) - {:ok, input} - end - end - - defp get_handler_pid(context) do - Map.get(context.metadata, :webhook_handler_pid) || - Map.get(context.metadata, "webhook_handler_pid") - end -end diff --git a/lib/fizz/steps/executors/webhook_trigger.ex b/lib/fizz/steps/executors/webhook_trigger.ex deleted file mode 100644 index 94676b7..0000000 --- a/lib/fizz/steps/executors/webhook_trigger.ex +++ /dev/null @@ -1,157 +0,0 @@ -defmodule Fizz.Steps.Executors.WebhookTrigger do - @moduledoc """ - Trigger node that outputs incoming webhook data. - - ## Configuration - - - `path_label` (optional) - A descriptive name for this webhook endpoint. - - `auth_token` (optional) - Required token to authorize requests. - - ## Output - - The full webhook payload including body, params, and headers. - """ - use Fizz.Steps.Definition, - id: "webhook_trigger", - name: "Webhook Trigger", - category: "Triggers", - description: "Accepts incoming HTTP requests to start the workflow", - icon: "hero-bolt", - kind: :trigger - - @config_schema %{ - "type" => "object", - "properties" => %{ - "path" => %{ - "type" => "string", - "title" => "Webhook Path", - "description" => "Custom slug for the webhook endpoint" - }, - "http_method" => %{ - "type" => "string", - "title" => "HTTP Method", - "enum" => ["GET", "POST", "PUT", "PATCH", "DELETE", "ANY"], - "default" => "POST" - }, - "body_schema" => %{ - "type" => "object", - "title" => "Request Body Schema", - "description" => "JSON Schema describing expected request body" - }, - "params_schema" => %{ - "type" => "object", - "title" => "Query Parameters Schema", - "description" => "JSON Schema describing expected query params" - }, - "validate_input" => %{ - "type" => "boolean", - "title" => "Validate Input", - "default" => false, - "description" => "Reject requests that do not match declared schemas" - }, - "response_mode" => %{ - "type" => "string", - "title" => "Response Mode", - "enum" => ["immediate", "on_completion", "on_respond_node"], - "default" => "immediate" - }, - "auth_token" => %{ - "type" => "string", - "title" => "Auth Token", - "description" => "Optional token to protect this webhook" - } - } - } - - @impl true - def default_config do - %{ - "path" => Ecto.UUID.generate(), - "http_method" => "POST", - "response_mode" => "immediate", - "validate_input" => false - } - end - - @output_schema %{ - "type" => "object", - "properties" => %{ - "body" => %{"type" => "object"}, - "headers" => %{"type" => "object"}, - "params" => %{"type" => "object"}, - "method" => %{"type" => "string"} - } - } - - @behaviour Fizz.Steps.Executors.Behaviour - - @impl true - def execute(config, input, _context) do - # For triggers, 'input' is the trigger payload provided by the runtime - case validate_payload(input, config) do - :ok -> {:ok, input} - {:error, errors} -> {:error, {:validation_failed, errors}} - end - end - - @doc false - def validate_payload(input, config) do - if Map.get(config, "validate_input", false) do - validate_against_schema(input, config) - else - :ok - end - end - - @impl true - def effective_output_schema(config) do - @output_schema - |> maybe_put_schema("body", Map.get(config, "body_schema")) - |> maybe_put_schema("params", Map.get(config, "params_schema")) - end - - defp maybe_put_schema(schema, _key, nil), do: schema - - defp maybe_put_schema(schema, key, custom_schema) do - update_in(schema, ["properties", key], fn _ -> custom_schema end) - end - - defp validate_against_schema(input, config) do - validations = [ - {"body", Map.get(input, "body"), Map.get(config, "body_schema")}, - {"params", Map.get(input, "params"), Map.get(config, "params_schema")} - ] - - errors = - Enum.reduce(validations, [], fn {field, value, schema}, acc -> - case validate_field_schema(field, value, schema) do - :ok -> acc - {:error, error} -> [error | acc] - end - end) - - if errors == [] do - :ok - else - {:error, Enum.reverse(errors)} - end - end - - defp validate_field_schema(_field, _value, nil), do: :ok - - defp validate_field_schema(field, value, schema) do - case JSV.build(schema) do - {:ok, compiled} -> - case JSV.validate(value, compiled) do - {:ok, _} -> - :ok - - {:error, error} -> - {:error, %{field: field, errors: JSV.normalize_error(error)}} - end - - {:error, error} -> - {:error, %{field: field, errors: %{message: Exception.message(error)}}} - end - end -end diff --git a/lib/fizz/steps/registry.ex b/lib/fizz/steps/registry.ex index 90cd85d..0a1088a 100644 --- a/lib/fizz/steps/registry.ex +++ b/lib/fizz/steps/registry.ex @@ -254,9 +254,7 @@ defmodule Fizz.Steps.Registry do Fizz.Steps.Executors.Aggregator, Fizz.Steps.Executors.Splitter, Fizz.Steps.Executors.Join, - Fizz.Steps.Executors.WebhookTrigger, Fizz.Steps.Executors.ScheduleTrigger, - Fizz.Steps.Executors.RespondToWebhook, Fizz.Steps.Executors.Wait, # -- AI -- diff --git a/lib/fizz/workers/execution_worker.ex b/lib/fizz/workers/execution_worker.ex deleted file mode 100644 index b6b4179..0000000 --- a/lib/fizz/workers/execution_worker.ex +++ /dev/null @@ -1,203 +0,0 @@ -defmodule Fizz.Workers.ExecutionWorker do - @moduledoc """ - Oban worker for executing workflows through the Runtime Execution Supervisor. - - ## Role in Architecture - - This is the **job queue entry point** — a thin wrapper that handles async/background job concerns: - - - Receives Oban jobs from the queue - - Receives Oban jobs from the queue - - Starts or attaches to execution processes via `Fizz.Runtime.Execution.Supervisor` - - Monitors execution processes and waits for completion - - Returns Oban-compatible results (:ok, {:error, ...}) - - ## Configuration - - - Queue: `:executions` - - Max attempts: 1 (failures are terminal, no automatic retries) - - Unique: prevents duplicate jobs for same execution within 60 seconds - """ - - use Oban.Worker, - queue: :executions, - max_attempts: 1, - unique: [period: 60, keys: [:execution_id]] - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: args}) do - execution_id = Map.fetch!(args, "execution_id") - - Logger.metadata(execution_id: execution_id) - Logger.info("Starting workflow execution job") - - # If this was a scheduled trigger, schedule the next one - maybe_schedule_next(execution_id) - - # Start the execution process (or attach to existing) - case Fizz.Runtime.Execution.Supervisor.start_execution(execution_id) do - {:ok, pid} -> - monitor_and_wait(pid, execution_id) - - {:error, {:already_started, pid}} -> - Logger.info("Execution already running, attaching", execution_id: execution_id) - monitor_and_wait(pid, execution_id) - - {:error, reason} -> - Logger.error("Failed to start execution process", - execution_id: execution_id, - reason: inspect(reason) - ) - - # Will retry if max_attempts > 1, but configured to 1 - {:error, reason} - end - end - - @doc false - def maybe_schedule_next(execution_id) do - execution = - Fizz.Repo.get(Fizz.Executions.Execution, execution_id) - |> Fizz.Repo.preload(workflow: :draft) - - case execution do - %{trigger: %{type: :schedule}, status: :pending} -> - schedule_next_run(execution) - - _ -> - :ok - end - end - - @doc false - def schedule_next_run(execution) do - # Look for interval or cron in trigger config - config = execution.trigger.data || %{} - interval_sec = Map.get(config, "interval_seconds") || Map.get(config, "interval") - - if interval_sec do - next_run = DateTime.add(DateTime.utc_now(), interval_sec, :second) - - Logger.info( - "Scheduling next execution for workflow #{execution.workflow_id} in #{interval_sec}s" - ) - - # Create a NEW execution for the next run - # The trigger info should be preserved - attrs = %{ - workflow_id: execution.workflow_id, - execution_type: execution.execution_type, - trigger: %{ - "type" => "schedule", - "data" => config - } - } - - case Fizz.Executions.create_execution(nil, attrs) do - {:ok, next_execution} -> - enqueue(next_execution.id, scheduled_at: next_run) - - {:error, reason} -> - Logger.error("Failed to schedule next execution: #{inspect(reason)}") - end - else - Logger.warning("Schedule trigger found but no interval/cron configured", - execution_id: execution.id - ) - end - end - - @doc """ - Runs an execution synchronously and waits for it to finish. - """ - def run_sync(execution_id) do - case Fizz.Runtime.Execution.Supervisor.start_execution(execution_id) do - {:ok, pid} -> - monitor_and_wait(pid, execution_id) - - {:error, {:already_started, pid}} -> - monitor_and_wait(pid, execution_id) - - {:error, reason} -> - {:error, reason} - end - end - - defp monitor_and_wait(pid, execution_id) do - ref = Process.monitor(pid) - - receive do - {:DOWN, ^ref, :process, _pid, :normal} -> - # Execution completed explicitly (Server stops with :normal on finish) - Logger.info("Execution process finished normally", execution_id: execution_id) - :ok - - {:DOWN, ^ref, :process, _pid, :shutdown} -> - :ok - - {:DOWN, ^ref, :process, _pid, reason} -> - Logger.error("Execution process crashed or failed", - execution_id: execution_id, - reason: inspect(reason) - ) - - {:error, reason} - end - end - - # ============================================================================ - # Job Creation Helpers - # ============================================================================ - - @doc """ - Creates job args for an execution. - - Includes trace context propagation for distributed tracing. - """ - def build_args(execution_id, opts \\ []) do - args = %{ - "execution_id" => execution_id - } - - # Add any additional metadata - case Keyword.get(opts, :metadata) do - nil -> args - metadata when is_map(metadata) -> Map.merge(args, metadata) - end - end - - @doc """ - Creates a new Oban job for the given execution. - - Returns `{:ok, job}` or `{:error, changeset}`. - - ## Options - - - `:scheduled_at` - Schedule the job for a future time - - `:priority` - Job priority (0-3, lower is higher priority) - - `:metadata` - Additional metadata to include in job args - """ - def new_job(execution_id, opts \\ []) do - args = build_args(execution_id, opts) - - job_opts = - opts - |> Keyword.take([:scheduled_at, :priority]) - |> Keyword.put_new(:priority, 1) - - __MODULE__.new(args, job_opts) - end - - @doc """ - Inserts a job for the given execution. - - Returns `{:ok, job}` or `{:error, changeset}`. - """ - def enqueue(execution_id, opts \\ []) do - execution_id - |> new_job(opts) - |> Oban.insert() - end -end diff --git a/lib/fizz/workers/scheduled_trigger_worker.ex b/lib/fizz/workers/scheduled_trigger_worker.ex deleted file mode 100644 index dc51332..0000000 --- a/lib/fizz/workers/scheduled_trigger_worker.ex +++ /dev/null @@ -1,48 +0,0 @@ -defmodule Fizz.Workers.ScheduledTriggerWorker do - @moduledoc """ - Oban worker that triggers a workflow execution on a schedule. - """ - use Oban.Worker, queue: :executions, max_attempts: 1 - - require Logger - alias Fizz.Executions - alias Fizz.Workers.ExecutionWorker - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"workflow_id" => workflow_id, "config" => config}}) do - Logger.info("Executing scheduled trigger for workflow: #{workflow_id}") - - attrs = %{ - workflow_id: workflow_id, - execution_type: :production, - trigger: %{ - "type" => "schedule", - "data" => config - } - } - - # Use a system scope or similar if needed, here passing nil for now - case Executions.create_execution(nil, attrs) do - {:ok, execution} -> - # Enqueue the execution worker to actually run it - ExecutionWorker.enqueue(execution.id) - :ok - - {:error, reason} -> - Logger.error( - "Failed to create scheduled execution for #{workflow_id}: #{inspect(reason)}" - ) - - {:error, reason} - end - end - - @doc """ - Enqueues a scheduled trigger job. - """ - def enqueue(workflow_id, config) do - %{workflow_id: workflow_id, config: config} - |> new() - |> Oban.insert() - end -end diff --git a/lib/fizz/workflows.ex b/lib/fizz/workflows.ex index 7dc727d..fa749ea 100644 --- a/lib/fizz/workflows.ex +++ b/lib/fizz/workflows.ex @@ -137,55 +137,6 @@ defmodule Fizz.Workflows do {:ok, Workflow.t()} | {:error, :not_found} def get_workflow(scope, id), do: do_get_workflow(id, scope) - @doc """ - Finds an active published workflow by its configured webhook path and method. - """ - @spec get_active_workflow_by_webhook(String.t(), String.t()) :: Workflow.t() | nil - def get_active_workflow_by_webhook(path, method) do - # method should be uppercase for consistency - method = String.upcase(method) - - query = - from w in Workflow, - join: v in assoc(w, :published_version), - where: w.status == :active, - where: - fragment( - "EXISTS (SELECT 1 FROM jsonb_array_elements(?) AS s WHERE s->>'type_id' = 'webhook_trigger' AND COALESCE(s->'config'->>'path', s->>'id') = ? AND (s->'config'->>'http_method' = ? OR s->'config'->>'http_method' = 'ANY' OR (s->'config'->>'http_method' IS NULL AND ? = 'POST')))", - v.steps, - ^path, - ^method, - ^method - ), - limit: 1 - - Repo.one(query) - end - - @doc """ - Finds a workflow by its DRAFT webhook path and method. - Ignores status (allows drafts). - """ - @spec get_workflow_by_webhook_draft_path(String.t(), String.t()) :: Workflow.t() | nil - def get_workflow_by_webhook_draft_path(path, method) do - method = String.upcase(method) - - query = - from w in Workflow, - join: d in assoc(w, :draft), - where: - fragment( - "EXISTS (SELECT 1 FROM jsonb_array_elements(?) AS s WHERE s->>'type_id' = 'webhook_trigger' AND COALESCE(s->'config'->>'path', s->>'id') = ? AND (s->'config'->>'http_method' = ? OR s->'config'->>'http_method' = 'ANY' OR (s->'config'->>'http_method' IS NULL AND ? = 'POST')))", - d.steps, - ^path, - ^method, - ^method - ), - limit: 1 - - Repo.one(query) - end - defp do_get_workflow(id, scope) do case Repo.get(Workflow, id) do nil -> @@ -283,7 +234,6 @@ defmodule Fizz.Workflows do {:ok, Workflow.t()} | {:error, :access_denied} def delete_workflow(%Scope{} = scope, %Workflow{} = workflow) do if Scope.can_edit_workflow?(scope, workflow) do - Fizz.Runtime.Triggers.Activator.deactivate(workflow.id) Repo.delete(workflow) else {:error, :access_denied} @@ -296,7 +246,6 @@ defmodule Fizz.Workflows do @spec archive_workflow(Scope.t(), Workflow.t()) :: {:ok, Workflow.t()} | {:error, Ecto.Changeset.t() | :access_denied} def archive_workflow(%Scope{} = scope, %Workflow{} = workflow) do - Fizz.Runtime.Triggers.Activator.deactivate(workflow.id) update_workflow(scope, workflow, %{status: :archived}) end @@ -404,7 +353,6 @@ defmodule Fizz.Workflows do end) |> case do {:ok, {updated_workflow, version}} -> - Fizz.Runtime.Triggers.Activator.activate(updated_workflow) {:ok, {updated_workflow, version}} error -> @@ -547,7 +495,7 @@ defmodule Fizz.Workflows do # Trigger Functions # ============================================================================ - @trigger_type_ids ["webhook_trigger", "schedule_trigger", "manual_input", "event_trigger"] + @trigger_type_ids ["schedule_trigger", "manual_input", "event_trigger"] @doc "Returns all trigger steps for the workflow." @spec triggers(Workflow.t()) :: [map()] @@ -597,7 +545,6 @@ defmodule Fizz.Workflows do type_id in @trigger_type_ids end - defp trigger_type_to_step_type_id(:webhook), do: "webhook_trigger" defp trigger_type_to_step_type_id(:schedule), do: "schedule_trigger" defp trigger_type_to_step_type_id(:manual), do: "manual_input" defp trigger_type_to_step_type_id(:event), do: "event_trigger" diff --git a/lib/fizz/workflows/contract.ex b/lib/fizz/workflows/contract.ex index e693541..fd5b9bb 100644 --- a/lib/fizz/workflows/contract.ex +++ b/lib/fizz/workflows/contract.ex @@ -29,7 +29,7 @@ defmodule Fizz.Workflows.Contract do @derive Jason.Encoder defstruct inputs: [], outputs: [] - @trigger_type_ids ["manual_input", "webhook_trigger", "schedule_trigger", "event_trigger"] + @trigger_type_ids ["manual_input", "schedule_trigger", "event_trigger"] @output_type_ids ["workflow_output", "data_output"] @doc """ diff --git a/lib/fizz_web/formatters.ex b/lib/fizz_web/formatters.ex index 2fa9b6a..b1b1e25 100644 --- a/lib/fizz_web/formatters.ex +++ b/lib/fizz_web/formatters.ex @@ -97,8 +97,6 @@ defmodule FizzWeb.Formatters do defp trigger_type_label(:manual), do: "Manual" defp trigger_type_label("schedule"), do: "Scheduled" defp trigger_type_label(:schedule), do: "Scheduled" - defp trigger_type_label("webhook"), do: "Webhook" - defp trigger_type_label(:webhook), do: "Webhook" defp trigger_type_label("event"), do: "Event" defp trigger_type_label(:event), do: "Event" defp trigger_type_label(type), do: to_string(type) diff --git a/lib/fizz_web/live/execution_live/show.ex b/lib/fizz_web/live/execution_live/show.ex index ce37543..6fda2e5 100644 --- a/lib/fizz_web/live/execution_live/show.ex +++ b/lib/fizz_web/live/execution_live/show.ex @@ -197,22 +197,10 @@ defmodule FizzWeb.ExecutionLive.Show do <.icon name="hero-squares-2x2" class="size-4" /> Workflow details - <.link - id="execution-debug-link" - navigate={Paths.workflow_edit_path(@current_scope, @workflow.id, @execution.id)} - class="inline-flex items-center gap-2 rounded-full border border-amber-400/40 bg-amber-500/10 px-4 py-2 text-xs font-semibold text-amber-800 transition hover:border-amber-400/70 hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100" - > - <.icon name="hero-bug-ant" class="size-4" /> - Debug in editor - - <.link - id="execution-edit-link" - navigate={Paths.workflow_edit_path(@current_scope, @workflow.id)} - class="inline-flex items-center gap-2 rounded-full border border-transparent bg-primary px-4 py-2 text-xs font-semibold text-primary-content shadow-sm transition hover:bg-primary/90" - > - <.icon name="hero-play" class="size-4" /> - Open editor - +
+ <.icon name="hero-exclamation-triangle" class="size-4" /> + Runtime unavailable +
diff --git a/lib/fizz_web/live/workflow_live/edit.ex b/lib/fizz_web/live/workflow_live/edit.ex deleted file mode 100644 index 63c8b76..0000000 --- a/lib/fizz_web/live/workflow_live/edit.ex +++ /dev/null @@ -1,2436 +0,0 @@ -defmodule FizzWeb.WorkflowLive.Edit do - @moduledoc """ - LiveView for designing and editing workflows with real-time collaboration. - """ - use FizzWeb, :live_view - - alias Fizz.Accounts - alias Fizz.Workflows - alias Fizz.Repo - alias Fizz.Steps - alias Fizz.Steps.Registry, as: StepRegistry - alias Fizz.Collaboration.EditorState - alias Fizz.Collaboration.EditSession.{Server, Presence, PubSub, Operations} - alias Fizz.Executions - alias Fizz.Executions.Execution - alias Fizz.Executions.PubSub, as: ExecutionPubSub - alias Fizz.Runtime.Execution.Supervisor, as: ExecutionSupervisor - alias FizzWeb.WorkflowLive.Edit.Command - alias FizzWeb.WorkflowLive.Edit.ExpressionPreview - alias FizzWeb.WorkflowLive.Edit.PresenceFormatter - alias FizzWeb.WorkflowLive.Edit.EditStepProjection - alias FizzWeb.WorkflowLive.Paths - alias Ecto.UUID - require Logger - - @execution_lifecycle_events [ - :execution_started, - :execution_updated, - :execution_completed, - :execution_cancelled, - :execution_failed - ] - @step_lifecycle_events [ - :step_started, - :step_completed, - :step_failed, - :step_skipped, - :step_cancelled - ] - @group_name_font_size_default 14 - @group_name_font_size_min 10 - @group_name_font_size_max 32 - - @impl true - def mount(%{"workspace_id" => workspace_id, "id" => id} = params, _session, socket) do - debug_execution_id = normalize_debug_execution_id(params) - - case Accounts.build_scope_for_workspace(socket.assigns.current_scope, workspace_id) do - {:ok, scope} -> - user = scope.user - - case Workflows.get_workflow_with_draft(scope, id) do - {:ok, workflow} -> - case PubSub.authorize_edit(scope, workflow.id) do - :ok -> - step_types = Steps.list_types() - node_library_items = Steps.list_library_items() - - socket = - socket - |> assign(:current_scope, scope) - |> assign(:page_title, "Editing #{workflow.name}") - |> assign(:workflow, workflow) - |> assign(:step_types, step_types) - |> assign(:node_library_items, node_library_items) - |> assign(:editor_state, %EditorState{workflow_id: workflow.id}) - |> assign(:presences, []) - |> assign(:current_user_id, user.id) - |> assign(:execution, nil) - |> assign(:step_executions, []) - |> assign(:execution_id, nil) - |> assign(:expression_previews, %{}) - |> assign(:webhook_execution_subscribed, false) - |> assign(:undo_state, nil) - |> assign(:collab_seq, 0) - |> assign(:debug_execution_id, nil) - - # Only set up collaboration when WebSocket is connected - socket = - if connected?(socket) do - case setup_collaboration(socket, workflow.id, user) do - {:ok, collaboration_socket} -> - maybe_load_debug_execution(collaboration_socket, debug_execution_id) - - {:error, collaboration_socket} -> - collaboration_socket - end - else - socket - end - - {:ok, socket, layout: false} - - {:error, :unauthorized} -> - {:ok, - socket - |> assign(:current_scope, scope) - |> put_flash(:error, "You do not have permission to edit this workflow") - |> redirect(to: Paths.workflow_show_path(scope, workflow.id))} - end - - {:error, :not_found} -> - {:ok, - socket - |> assign(:current_scope, scope) - |> put_flash(:error, "Workflow not found") - |> redirect(to: Paths.workflows_index_path(scope))} - end - - {:error, _reason} -> - {:ok, - socket - |> put_flash(:error, "Workspace not found") - |> redirect(to: ~p"/workspaces")} - end - end - - # ============================================================================= - # Collaboration Setup - # ============================================================================= - - defp setup_collaboration(socket, workflow_id, user) do - # Ensure edit session server is running - case Fizz.Collaboration.EditSession.Supervisor.ensure_session( - socket.assigns.current_scope, - workflow_id - ) do - {:ok, _pid} -> - {:ok, do_setup_collaboration(socket, workflow_id, user)} - - {:error, reason} -> - Logger.error("Failed to start edit session", - workflow_id: workflow_id, - reason: inspect(reason) - ) - - {:error, - socket - |> put_flash(:error, "Unable to start collaborative editing session") - |> redirect(to: Paths.workflow_show_path(socket.assigns.current_scope, workflow_id))} - end - end - - defp do_setup_collaboration(socket, workflow_id, user) do - # Subscribe to operation broadcasts - :ok = Phoenix.PubSub.subscribe(Fizz.PubSub, PubSub.session_topic(workflow_id)) - - # Subscribe to presence topic for diffs - :ok = Phoenix.PubSub.subscribe(Fizz.PubSub, Presence.topic(workflow_id)) - - # Track this user's presence - {:ok, _} = Presence.track_user(workflow_id, user, socket) - - # Get initial editor state - editor_state = - case Server.get_editor_state(workflow_id) do - {:ok, state} -> state - _ -> %EditorState{workflow_id: workflow_id} - end - - # Get initial presence list - presences = PresenceFormatter.format(Presence.list_users(workflow_id)) - - {draft, editor_state, collab_seq} = - case Server.get_sync_state(workflow_id) do - {:ok, %{type: :full_sync, draft: draft, editor_state: sync_editor_state, seq: seq}} -> - {draft, deserialize_editor_state(sync_editor_state, workflow_id), seq} - - _ -> - {socket.assigns.workflow.draft, editor_state, 0} - end - - # Get latest execution - {execution, step_executions} = - case Executions.list_workflow_executions( - socket.assigns.current_scope, - socket.assigns.workflow, - limit: 1 - ) do - [latest] -> - {:ok, full_execution} = - Executions.get_execution_with_steps(socket.assigns.current_scope, latest.id) - - {full_execution, full_execution.step_executions} - - [] -> - {nil, []} - end - - socket = - socket - |> assign(:workflow, %{socket.assigns.workflow | draft: draft}) - |> assign(:editor_state, editor_state) - |> assign(:presences, presences) - |> assign(:execution, execution) - |> assign(:execution_id, if(execution, do: execution.id, else: nil)) - |> assign(:step_executions, step_executions) - |> assign(:undo_state, fetch_undo_state(workflow_id, user.id)) - |> assign(:collab_seq, collab_seq) - |> maybe_toggle_webhook_subscription(editor_state) - - push_undo_state(socket) - end - - defp normalize_debug_execution_id(params) when is_map(params) do - case Map.get(params, "debug_execution_id") do - nil -> nil - "" -> nil - id -> id - end - end - - defp normalize_debug_execution_id(_params), do: nil - - defp maybe_load_debug_execution(socket, nil), do: socket - - defp maybe_load_debug_execution(socket, debug_execution_id) do - case Executions.get_execution_with_steps(socket.assigns.current_scope, debug_execution_id) do - {:ok, execution} -> - if execution.workflow_id == socket.assigns.workflow.id do - _ = subscribe_execution(socket.assigns.current_scope, execution.id) - - socket - |> assign(:execution, execution) - |> assign(:execution_id, execution.id) - |> assign(:step_executions, execution.step_executions || []) - |> assign(:debug_execution_id, execution.id) - else - socket - |> assign(:debug_execution_id, nil) - |> put_flash(:error, "Debug execution does not belong to this workflow") - end - - {:error, :not_found} -> - socket - |> assign(:debug_execution_id, nil) - |> put_flash(:error, "Debug execution not found") - end - end - - @impl true - def render(assigns) do - ~H""" - -
- <.vue - v-component="WorkflowEditor" - v-ssr={false} - v-socket={@socket} - workflow={@workflow} - stepTypes={@step_types} - nodeLibraryItems={@node_library_items} - editorState={@editor_state} - presences={@presences} - currentUserId={@current_user_id} - execution={@execution} - stepExecutions={@step_executions} - undoState={@undo_state} - collabSeq={@collab_seq} - v-on:editor_command={JS.push("editor_command")} - expressionPreviews={@expression_previews} - debugExecutionId={@debug_execution_id} - /> -
-
- """ - end - - @impl true - def handle_event("editor_command", params, socket) do - with {:ok, command, payload} <- Command.parse(params), - true <- Command.valid?(command) do - handle_event(command, payload, socket) - else - false -> - Logger.debug("Ignoring unsupported workflow editor command", - command: Map.get(params, "type") - ) - - {:noreply, socket} - - {:error, :invalid} -> - Logger.debug("Ignoring invalid workflow editor command payload", payload: inspect(params)) - {:noreply, socket} - end - end - - # ============================================================================= - # Step Operations - # ============================================================================= - - @impl true - def handle_event("add_step", %{"type_id" => type_id, "position" => pos} = params, socket) do - step_type_name = - case StepRegistry.get(type_id) do - {:ok, type} -> Steps.default_step_name(type.name) - _ -> "Step" - end - - steps = socket.assigns.workflow.draft.steps || [] - {unique_name, step_id} = Fizz.Workflows.generate_unique_step_identity(steps, step_type_name) - - step = %{ - id: step_id, - type_id: type_id, - name: unique_name, - config: StepRegistry.get_default_config(type_id), - position: pos, - notes: nil - } - - payload = - %{step: step} - |> maybe_put_group_id(Map.get(params, "group_id")) - |> maybe_put_step_size(parse_step_size(params)) - - case build_add_step_auto_connect_connection(params, step_id) do - nil -> - apply_operation(socket, :add_step, payload) - - connection -> - undo_group_id = UUID.generate() - undo_label = "Add Step" - - step_operation = - build_operation(socket, :add_step, payload, %{ - undo_group_id: undo_group_id, - undo_label: undo_label - }) - - case Server.apply_operation(socket.assigns.workflow.id, step_operation) do - {:ok, _result} -> - connection_operation = - build_operation(socket, :add_connection, %{connection: connection}, %{ - undo_group_id: undo_group_id, - undo_label: undo_label - }) - - case Server.apply_operation(socket.assigns.workflow.id, connection_operation) do - {:ok, _result} -> - {:noreply, push_undo_state(socket)} - - {:error, reason} -> - Logger.warning("Auto-connect failed after add_step", - reason: inspect(reason), - step_id: step_id, - workflow_id: socket.assigns.workflow.id - ) - - if step_exists_in_session_draft?(socket.assigns.workflow.id, step_id) do - {:noreply, - socket - |> push_undo_state() - |> put_flash(:error, "Step added but auto-connect failed")} - else - {:noreply, put_flash(socket, :error, "Operation failed")} - end - end - - {:error, reason} -> - Logger.warning("Operation failed: #{inspect(reason)}") - {:noreply, put_flash(socket, :error, "Operation failed")} - end - end - end - - @impl true - def handle_event("duplicate_steps", %{"step_ids" => step_ids} = params, socket) do - step_ids = step_ids |> List.wrap() |> Enum.uniq() - - draft = socket.assigns.workflow.draft - steps = if(draft, do: draft.steps || [], else: []) - connections = if(draft, do: draft.connections || [], else: []) - groups = if(draft, do: draft.groups || [], else: []) - - if step_ids == [] or steps == [] do - {:noreply, socket} - else - position_by_step_id = Map.get(params, "position_by_step_id", %{}) - - incoming_group_map = - case Map.get(params, "group_id_by_step_id") do - %{} = map -> map - _ -> %{} - end - - group_lookup = build_group_lookup(groups) - group_id_by_step_id = Map.merge(group_lookup, incoming_group_map) - - {new_steps, id_map, group_id_by_new_step_id} = - build_duplicate_steps(steps, step_ids, position_by_step_id, group_id_by_step_id) - - new_connections = build_duplicate_connections(connections, step_ids, id_map) - - undo_group_id = UUID.generate() - - undo_label = - "Duplicate #{length(new_steps)} Step#{if(length(new_steps) == 1, do: "", else: "s")}" - - operations = - Enum.map(new_steps, fn step -> - step_id = fetch_field(step, :id) - - payload = - case Map.get(group_id_by_new_step_id, step_id) do - nil -> %{step: step} - group_id -> %{step: step, group_id: group_id} - end - - {:add_step, payload, %{undo_group_id: undo_group_id, undo_label: undo_label}} - end) ++ - Enum.map(new_connections, fn connection -> - {:add_connection, %{connection: connection}, - %{undo_group_id: undo_group_id, undo_label: undo_label}} - end) - - case apply_operations(socket, operations) do - :ok -> - new_step_ids = Enum.map(new_steps, &fetch_field(&1, :id)) - - socket = - socket - |> push_event("workflow:duplicate_selection", %{step_ids: new_step_ids}) - |> push_undo_state() - - {:noreply, socket} - - {:error, reason} -> - Logger.warning("duplicate_steps failed: #{inspect(reason)}") - {:noreply, put_flash(socket, :error, "Unable to duplicate steps")} - end - end - end - - @impl true - def handle_event("move_step", %{"step_id" => step_id, "position" => pos}, socket) do - apply_operation(socket, :update_step_position, %{step_id: step_id, position: pos}) - end - - @impl true - def handle_event("move_steps", %{"step_positions" => step_positions}, socket) - when is_map(step_positions) do - normalized_positions = - Enum.reduce(step_positions, %{}, fn {step_id, position}, acc -> - Map.put(acc, step_id, normalize_position(position || %{})) - end) - - if map_size(normalized_positions) == 0 do - {:noreply, socket} - else - apply_operation(socket, :update_step_positions, %{step_positions: normalized_positions}) - end - end - - @impl true - def handle_event("move_steps", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event("tidy_layout", params, socket) do - steps = params |> Map.get("steps", []) |> List.wrap() - groups = params |> Map.get("groups", []) |> List.wrap() - label = Map.get(params, "label") || "Tidy Workflow" - undo_group_id = UUID.generate() - - group_ops = - Enum.map(groups, fn group -> - group_id = fetch_payload_value(group, :group_id) - position = normalize_group_position(fetch_payload_value(group, :position) || %{}) - - {:update_group, %{group_id: group_id, changes: %{position: position}}, - %{undo_group_id: undo_group_id, undo_label: label}} - end) - - step_ops = - Enum.map(steps, fn step -> - step_id = fetch_payload_value(step, :step_id) - position = normalize_position(fetch_payload_value(step, :position) || %{}) - - {:update_step_position, %{step_id: step_id, position: position}, - %{undo_group_id: undo_group_id, undo_label: label}} - end) - - case apply_operations(socket, group_ops ++ step_ops) do - :ok -> - {:noreply, push_undo_state(socket)} - - {:error, reason} -> - Logger.warning("tidy_layout failed: #{inspect(reason)}") - {:noreply, put_flash(socket, :error, "Unable to tidy layout")} - end - end - - @impl true - def handle_event("update_step", %{"step_id" => step_id, "changes" => changes}, socket) do - apply_operation(socket, :update_step_metadata, %{step_id: step_id, changes: changes}) - end - - @impl true - def handle_event("remove_step", %{"step_id" => step_id}, socket) do - Logger.info("Received remove_step event for step: #{step_id}") - apply_operation(socket, :remove_step, %{step_id: step_id}) - end - - @impl true - def handle_event("add_group", params, socket) do - step_ids = - params - |> Map.get("step_ids", []) - |> List.wrap() - |> Enum.uniq() - - if step_ids == [] do - {:noreply, socket} - else - group = %{ - id: UUID.generate(), - name: Map.get(params, "name", "Group"), - step_ids: step_ids, - position: normalize_group_position(Map.get(params, "position", %{})), - color: Map.get(params, "color"), - font_size: parse_group_font_size(Map.get(params, "font_size")), - collapsed: Map.get(params, "collapsed", false) - } - - step_positions = - case Map.get(params, "step_positions") do - %{} = positions -> positions - _ -> %{} - end - - apply_operation(socket, :add_group, %{group: group, step_positions: step_positions}) - end - end - - @impl true - def handle_event("update_group", %{"group_id" => group_id} = params, socket) do - changes = - params - |> Map.get("changes", %{}) - |> normalize_group_changes() - - apply_operation(socket, :update_group, %{group_id: group_id, changes: changes}) - end - - @impl true - def handle_event("remove_group", %{"group_id" => group_id}, socket) do - apply_operation(socket, :remove_group, %{group_id: group_id}) - end - - @impl true - def handle_event("set_group_membership", params, socket) do - group_id = - case Map.get(params, "group_id") do - "" -> nil - group_id -> group_id - end - - step_ids = - params - |> Map.get("step_ids", []) - |> List.wrap() - |> Enum.uniq() - - step_positions = - case Map.get(params, "step_positions") do - %{} = positions -> positions - _ -> %{} - end - - if step_ids == [] do - {:noreply, socket} - else - apply_operation(socket, :set_group_membership, %{ - group_id: group_id, - step_ids: step_ids, - step_positions: step_positions - }) - end - end - - @impl true - def handle_event("commit_drag_layout", params, socket) do - payload = normalize_commit_drag_layout_payload(params) - - if commit_drag_layout_empty?(payload) do - {:noreply, socket} - else - apply_operation(socket, :commit_drag_layout, payload) - end - end - - @impl true - def handle_event("add_connection", params, socket) do - connection = %{ - id: UUID.generate(), - source_step_id: params["source_step_id"], - target_step_id: params["target_step_id"], - source_output: params["source_output"] || "main", - target_input: params["target_input"] || "main" - } - - apply_operation(socket, :add_connection, %{connection: connection}) - end - - @impl true - def handle_event("remove_connection", %{"connection_id" => id}, socket) do - Logger.info("Received remove_connection event for connection: #{id}") - apply_operation(socket, :remove_connection, %{connection_id: id}) - end - - @impl true - def handle_event("undo", %{"count" => count}, socket) do - count = parse_count(count) - - case Server.undo(socket.assigns.workflow.id, socket.assigns.current_user_id, count) do - {:ok, result} -> - socket = - socket - |> push_event("workflow:undo_applied", %{success: true, label: result.label}) - |> push_undo_state() - - {:noreply, socket} - - {:error, {:conflict, reason, label}} -> - socket = - socket - |> push_event("workflow:undo_conflict", %{reason: inspect(reason), label: label}) - |> push_undo_state() - - {:noreply, socket} - - {:error, _reason} -> - socket = push_undo_state(socket) - {:noreply, socket} - end - end - - @impl true - def handle_event("redo", %{"count" => count}, socket) do - count = parse_count(count) - - case Server.redo(socket.assigns.workflow.id, socket.assigns.current_user_id, count) do - {:ok, result} -> - socket = - socket - |> push_event("workflow:redo_applied", %{success: true, label: result.label}) - |> push_undo_state() - - {:noreply, socket} - - {:error, {:conflict, reason, label}} -> - socket = - socket - |> push_event("workflow:redo_conflict", %{reason: inspect(reason), label: label}) - |> push_undo_state() - - {:noreply, socket} - - {:error, _reason} -> - socket = push_undo_state(socket) - {:noreply, socket} - end - end - - @impl true - def handle_event("navigate_revisions", _params, socket) do - scope = socket.assigns.current_scope - workflow = socket.assigns.workflow - {:noreply, push_navigate(socket, to: Paths.workflow_revisions_path(scope, workflow.id))} - end - - # ============================================================================= - # Editor State Operations - # ============================================================================= - - @impl true - def handle_event("pin_output", params, socket) do - step_id = fetch_payload_value(params, :step_id) - - if is_nil(step_id) do - {:noreply, socket} - else - output_data_present? = payload_has_key?(params, :output_data) - item_index = parse_item_index(params) - - output_data = - if output_data_present? do - fetch_payload_value(params, :output_data) - else - resolve_pin_output(socket.assigns.step_executions, step_id, item_index) - end - - if !output_data_present? and is_nil(output_data) do - {:noreply, put_flash(socket, :error, "No output data available to pin yet")} - else - apply_operation(socket, :pin_step_output, %{step_id: step_id, output_data: output_data}) - end - end - end - - @impl true - def handle_event("unpin_output", %{"step_id" => step_id}, socket) do - apply_operation(socket, :unpin_step_output, %{step_id: step_id}) - end - - @impl true - def handle_event("disable_step", %{"step_id" => step_id, "mode" => mode}, socket) do - mode_atom = if mode == "exclude", do: :exclude, else: :skip - apply_operation(socket, :disable_step, %{step_id: step_id, mode: mode_atom}) - end - - @impl true - def handle_event("enable_step", %{"step_id" => step_id}, socket) do - apply_operation(socket, :enable_step, %{step_id: step_id}) - end - - # ============================================================================= - # Presence/Collaboration Events - # ============================================================================= - - @impl true - def handle_event("mouse_move", params, socket) do - x = params["x"] - y = params["y"] - dragging_steps = params["dragging_steps"] - dragging_groups = params["dragging_groups"] - - cursor = - if is_number(x) and is_number(y) do - %{x: x, y: y} - else - nil - end - - Presence.update_interaction( - socket.assigns.workflow.id, - socket.assigns.current_user_id, - cursor, - dragging_steps, - dragging_groups - ) - - {:noreply, socket} - end - - @impl true - def handle_event("selection_changed", %{"step_ids" => step_ids}, socket) do - Presence.update_selection( - socket.assigns.workflow.id, - socket.assigns.current_user_id, - step_ids - ) - - {:noreply, socket} - end - - # ============================================================================= - # Workflow Operations - # ============================================================================= - - @impl true - def handle_event("save_workflow", _params, socket) do - case Server.persist_sync(socket.assigns.workflow.id) do - :ok -> - socket = refresh_workflow(socket) - {:noreply, put_flash(socket, :info, "Workflow draft saved")} - - :noop -> - {:noreply, put_flash(socket, :info, "No draft changes to save")} - - {:error, reason} -> - Logger.error("Failed to persist workflow draft: #{inspect(reason)}") - {:noreply, put_flash(socket, :error, "Failed to save workflow draft")} - end - end - - @impl true - def handle_event("publish_workflow", %{"version_tag" => version_tag} = params, socket) do - workflow = socket.assigns.workflow - scope = socket.assigns.current_scope - changelog = Map.get(params, "changelog", "") - - # First, persist any unsaved draft changes - _ = Server.persist_sync(workflow.id) - - version_attrs = %{ - version_tag: version_tag, - changelog: changelog - } - - case Workflows.publish_workflow(scope, workflow, version_attrs) do - {:ok, {updated_workflow, _version}} -> - socket = - socket - |> assign(:workflow, Repo.preload(updated_workflow, [:draft, :workspace], force: true)) - |> push_event("workflow:publish_result", %{success: true}) - |> put_flash(:info, "Workflow published as version #{version_tag}") - - {:noreply, socket} - - {:error, {:invalid_workflow, errors}} -> - error_msg = format_validation_errors(errors) - - socket = - socket - |> push_event("workflow:publish_result", %{success: false, error: error_msg}) - - {:noreply, socket} - - {:error, reason} -> - Logger.error("Failed to publish workflow: #{inspect(reason)}") - error_msg = format_publish_error(reason) - - socket = - socket - |> push_event("workflow:publish_result", %{success: false, error: error_msg}) - - {:noreply, socket} - end - end - - @impl true - def handle_event("run_test", _params, socket) do - case start_preview_execution(socket) do - {:ok, socket} -> - {:noreply, put_flash(socket, :info, "Test execution started")} - - {:error, reason, socket} -> - {:noreply, put_flash(socket, :error, "Failed to start test: #{reason}")} - end - end - - @impl true - def handle_event("run_node", %{"step_id" => step_id}, socket) do - case start_partial_execution(socket, step_id) do - {:ok, socket} -> - {:noreply, put_flash(socket, :info, "Partial execution started")} - - {:error, reason, socket} -> - {:noreply, put_flash(socket, :error, "Failed to run node: #{reason}")} - end - end - - @impl true - def handle_event( - "toggle_webhook_test", - %{"action" => action, "step_id" => step_id} = params, - socket - ) do - workflow_id = socket.assigns.workflow.id - - case action do - "start" -> - attrs = %{ - step_id: step_id, - path: Map.get(params, "path"), - method: Map.get(params, "method"), - user_id: socket.assigns.current_user_id - } - - case Server.enable_test_webhook(workflow_id, attrs) do - {:ok, _} -> - {:noreply, socket} - - {:error, reason} -> - {:noreply, - put_flash( - socket, - :error, - "Unable to enable test webhook: #{format_test_webhook_error(reason)}" - )} - end - - "stop" -> - _ = Server.disable_test_webhook(workflow_id, step_id) - {:noreply, socket} - - _ -> - {:noreply, socket} - end - end - - @impl true - def handle_event("cancel_execution", _params, socket) do - case socket.assigns.execution do - %Execution{} = execution -> - _ = maybe_stop_execution_process(execution.id) - - case Executions.cancel_execution(socket.assigns.current_scope, execution) do - {:ok, updated_execution} -> - {:noreply, assign(socket, :execution, updated_execution)} - - {:error, :already_terminal} -> - {:noreply, socket} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Unable to cancel execution")} - end - - _ -> - {:noreply, socket} - end - end - - @impl true - def handle_event( - "preview_expression", - %{"expression" => template, "step_id" => step_id, "field_key" => field_key}, - socket - ) do - result = - ExpressionPreview.evaluate( - %{ - workflow: socket.assigns.workflow, - editor_state: socket.assigns.editor_state, - execution: socket.assigns.execution, - step_executions: socket.assigns.step_executions - }, - template, - step_id - ) - - # Update previews in socket assigns - previews = socket.assigns.expression_previews - new_previews = Map.put(previews, "#{step_id}:#{field_key}", result) - - {:noreply, assign(socket, :expression_previews, new_previews)} - end - - @impl true - def handle_event( - "resolve_field_options", - %{"node_id" => node_id, "field_key" => field_key, "params" => params, "q" => q}, - socket - ) do - with {:ok, resolver_module} <- lookup_resolver(socket, node_id, field_key) do - context = %{ - execution: socket.assigns.execution, - step_executions: socket.assigns.step_executions, - workflow: socket.assigns.workflow, - current_scope: socket.assigns.current_scope - } - - safe_params = Map.new(params || %{}, fn {k, v} -> {to_string(k), v} end) - - args = %{ - q: q || "", - params: safe_params, - context: context - } - - case resolver_module.resolve(args) do - {:ok, options} -> - {:reply, %{options: options}, socket} - - {:error, reason} -> - Logger.error( - "Resolver #{inspect(resolver_module)} failed for #{node_id}/#{field_key}: #{inspect(reason)}" - ) - - {:reply, %{options: []}, socket} - end - else - {:error, reason} -> - Logger.error("Could not find resolver for #{node_id}/#{field_key}: #{inspect(reason)}") - {:reply, %{options: []}, socket} - end - end - - @impl true - def terminate(_reason, socket) do - _ = unsubscribe_execution(socket) - - if socket.assigns.webhook_execution_subscribed do - ExecutionPubSub.unsubscribe_workflow_executions(socket.assigns.workflow.id) - end - - :ok - end - - # ============================================================================= - # PubSub Message Handlers - # ============================================================================= - - # Handle operation broadcasts from the edit session server - @impl true - def handle_info({:operation_applied, operation}, socket) do - seq = parse_optional_non_negative_integer(fetch_payload_value(operation, :seq)) - - socket = push_operation_ack(socket, operation, seq) - - if stale_seq?(seq, socket.assigns.collab_seq) do - {:noreply, socket} - else - case Operations.apply(socket.assigns.workflow.draft, operation) do - {:ok, new_draft} -> - updated_workflow = %{socket.assigns.workflow | draft: new_draft} - - socket = - socket - |> assign(:workflow, updated_workflow) - |> maybe_assign_collab_seq(seq) - |> maybe_push_undo_state_for_operation(operation) - - {:noreply, socket} - - {:error, reason} -> - Logger.error( - "edit.ex: Failed to apply operation #{inspect(fetch_payload_value(operation, :type))}: #{inspect(reason)}. Reloading..." - ) - - case Server.get_sync_state(socket.assigns.workflow.id) do - {:ok, %{type: :full_sync, draft: draft, editor_state: editor_state, seq: sync_seq}} -> - deserialized_editor_state = - deserialize_editor_state(editor_state, socket.assigns.workflow.id) - - sync_seq = parse_optional_non_negative_integer(sync_seq) - - socket = - socket - |> assign(:workflow, %{socket.assigns.workflow | draft: draft}) - |> assign(:editor_state, deserialized_editor_state) - |> maybe_toggle_webhook_subscription(deserialized_editor_state) - |> maybe_assign_collab_seq(sync_seq) - |> push_undo_state() - - {:noreply, socket} - - _ -> - {:noreply, socket} - end - end - end - end - - # Handle editor state updates (pins, disabled steps, locks) - @impl true - def handle_info({:editor_state_updated, new_editor_state}, socket) do - socket = - socket - |> assign(:editor_state, new_editor_state) - |> maybe_toggle_webhook_subscription(new_editor_state) - - {:noreply, socket} - end - - @impl true - def handle_info({:webhook_test_execution, %{execution_id: execution_id}}, socket) do - case Executions.get_execution(socket.assigns.current_scope, execution_id) do - {:ok, execution} -> - {:noreply, switch_to_execution(socket, execution)} - - {:error, _} -> - {:noreply, socket} - end - end - - # Handle Phoenix.Presence diff broadcasts - # This is the standard format from Phoenix.Presence - @impl true - def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff", payload: diff}, socket) do - handle_presence_diff(socket, diff) - end - - # Handle lock events - @impl true - def handle_info({:lock_acquired, step_id, user_id}, socket) do - editor_state = - EditorState.put_lock(socket.assigns.editor_state, step_id, user_id, DateTime.utc_now()) - - {:noreply, assign(socket, :editor_state, editor_state)} - end - - @impl true - def handle_info({:lock_released, step_id}, socket) do - editor_state = EditorState.release_lock(socket.assigns.editor_state, step_id) - {:noreply, assign(socket, :editor_state, editor_state)} - end - - # Handle sync state (for reconnection) - @impl true - def handle_info({:sync_state, state}, socket) do - incoming_seq = - state - |> fetch_payload_value(:seq) - |> parse_optional_non_negative_integer() - - if stale_seq?(incoming_seq, socket.assigns.collab_seq) do - {:noreply, socket} - else - editor_state = deserialize_editor_state(state.editor_state, socket.assigns.workflow.id) - - socket = - socket - |> assign(:workflow, %{socket.assigns.workflow | draft: state.draft}) - |> assign(:editor_state, editor_state) - |> maybe_toggle_webhook_subscription(editor_state) - |> maybe_assign_collab_seq(incoming_seq) - |> push_undo_state() - - {:noreply, socket} - end - end - - @impl true - def handle_info({:execution_event, %{event_name: event_name} = event}, socket) - when event_name in @execution_lifecycle_events do - {:noreply, handle_execution_lifecycle_event(socket, event)} - end - - @impl true - def handle_info( - {:execution_event, %{event_name: event_name, payload: payload}}, - socket - ) - when event_name in @step_lifecycle_events do - socket = - if event_name == :step_failed do - step_id = fetch_payload_value(payload, :step_id) - error = fetch_payload_value(payload, :error) - put_flash(socket, :error, "Step #{step_id} failed: #{format_error_message(error)}") - else - socket - end - - {:noreply, update_step_executions(socket, event_name, payload)} - end - - # Catch-all for unhandled messages (useful for debugging) - @impl true - def handle_info(msg, socket) do - require Logger - Logger.debug("Unhandled message in WorkflowLive.Edit: #{inspect(msg)}") - {:noreply, socket} - end - - # ============================================================================= - # Private Helpers - # ============================================================================= - - defp format_validation_errors(errors) when is_list(errors) do - errors - |> Enum.take(3) - |> Enum.map_join(", ", fn - %{message: msg} -> msg - msg when is_binary(msg) -> msg - other -> inspect(other) - end) - end - - defp format_validation_errors(errors), do: inspect(errors) - - defp format_publish_error(:access_denied), - do: "You don't have permission to publish this workflow" - - defp format_publish_error(%Ecto.Changeset{} = changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) - |> Enum.map_join(", ", fn {field, errors} -> "#{field}: #{Enum.join(errors, ", ")}" end) - end - - defp format_publish_error(reason), do: "Failed to publish: #{inspect(reason)}" - - defp apply_operations(_socket, []), do: :ok - - defp apply_operations(socket, operations) do - Enum.reduce_while(operations, :ok, fn - {type, payload}, :ok -> - operation = build_operation(socket, type, payload, %{}) - - case Server.apply_operation(socket.assigns.workflow.id, operation) do - {:ok, _result} -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - - {type, payload, opts}, :ok -> - operation = build_operation(socket, type, payload, opts) - - case Server.apply_operation(socket.assigns.workflow.id, operation) do - {:ok, _result} -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - end) - end - - defp build_operation(socket, type, payload, opts) do - %{ - id: UUID.generate(), - type: type, - payload: payload, - user_id: socket.assigns.current_user_id, - client_seq: nil, - undo_group_id: Map.get(opts, :undo_group_id), - undo_label: Map.get(opts, :undo_label) - } - end - - defp build_duplicate_steps(steps, step_ids, position_by_step_id, group_id_by_step_id) do - step_lookup = Map.new(steps, fn step -> {fetch_field(step, :id), step} end) - - {new_steps, id_map, group_id_by_new_step_id, _existing_steps} = - Enum.reduce(step_ids, {[], %{}, %{}, steps}, fn step_id, - {acc, id_map, group_map, existing_steps} -> - case Map.get(step_lookup, step_id) do - nil -> - {acc, id_map, group_map, existing_steps} - - step -> - base_name = fetch_field(step, :name) || "Step" - - {unique_name, new_step_id} = - Workflows.generate_unique_step_identity(existing_steps, base_name) - - group_map = - case Map.get(group_id_by_step_id, step_id) do - nil -> group_map - group_id -> Map.put(group_map, new_step_id, group_id) - end - - new_step = %{ - id: new_step_id, - type_id: fetch_field(step, :type_id), - name: unique_name, - config: fetch_field(step, :config) || %{}, - position: - resolve_duplicate_position( - position_by_step_id, - step_id, - fetch_field(step, :position) - ), - notes: fetch_field(step, :notes) - } - - {[new_step | acc], Map.put(id_map, step_id, new_step_id), group_map, - [new_step | existing_steps]} - end - end) - - {Enum.reverse(new_steps), id_map, group_id_by_new_step_id} - end - - defp build_duplicate_connections(connections, step_ids, id_map) do - selected_ids = MapSet.new(step_ids) - - connections - |> Enum.reduce([], fn conn, acc -> - target_id = fetch_field(conn, :target_step_id) - - if MapSet.member?(selected_ids, target_id) do - source_id = fetch_field(conn, :source_step_id) - new_target = Map.get(id_map, target_id) - new_source = Map.get(id_map, source_id, source_id) - - if new_target && new_source do - [ - %{ - id: UUID.generate(), - source_step_id: new_source, - target_step_id: new_target, - source_output: fetch_field(conn, :source_output) || "main", - target_input: fetch_field(conn, :target_input) || "main" - } - | acc - ] - else - acc - end - else - acc - end - end) - |> Enum.reverse() - end - - defp build_group_lookup(groups) do - groups - |> List.wrap() - |> Enum.flat_map(fn group -> - group_id = fetch_field(group, :id) - step_ids = fetch_field(group, :step_ids) || [] - Enum.map(step_ids, &{&1, group_id}) - end) - |> Map.new() - end - - defp resolve_duplicate_position(position_by_step_id, step_id, fallback_position) do - case fetch_field(position_by_step_id, step_id) do - %{} = position -> normalize_position(position) - _ -> offset_position(fallback_position) - end - end - - defp normalize_position(position) when is_map(position) do - %{ - x: fetch_field(position, :x) || 0, - y: fetch_field(position, :y) || 0 - } - end - - defp normalize_group_position(position) when is_map(position) do - %{ - x: fetch_field(position, :x) || 0, - y: fetch_field(position, :y) || 0, - width: fetch_field(position, :width), - height: fetch_field(position, :height) - } - |> Enum.reject(fn {_key, value} -> is_nil(value) end) - |> Map.new() - end - - defp normalize_group_position(_position), do: %{} - - defp normalize_group_changes(changes) when is_map(changes) do - changes = normalize_optional_group_font_size(changes) - - position = - Map.get(changes, :position) || - Map.get(changes, "position") - - if is_map(position) do - Map.put(changes, :position, normalize_group_position(position)) - else - changes - end - end - - defp normalize_group_changes(_changes), do: %{} - - defp normalize_commit_drag_layout_payload(params) do - txn_id = - case fetch_payload_value(params, :txn_id) do - value when is_binary(value) and value != "" -> value - _ -> UUID.generate() - end - - base_seq = - params - |> fetch_payload_value(:base_seq) - |> parse_optional_non_negative_integer() - - groups = - params - |> fetch_payload_value(:groups) - |> List.wrap() - |> Enum.reduce([], fn group, acc -> - group_id = fetch_payload_value(group, :group_id) - position = normalize_group_position(fetch_payload_value(group, :position) || %{}) - - if is_binary(group_id) and group_id != "" do - [%{group_id: group_id, position: position} | acc] - else - acc - end - end) - |> Enum.reverse() - - step_positions = - case fetch_payload_value(params, :step_positions) do - positions when is_map(positions) -> - Enum.reduce(positions, %{}, fn {step_id, position}, acc -> - normalized_step_id = to_string(step_id) - - if normalized_step_id == "" do - acc - else - Map.put(acc, normalized_step_id, normalize_position(position || %{})) - end - end) - - _ -> - %{} - end - - group_id_by_step_id = - case fetch_payload_value(params, :group_id_by_step_id) do - memberships when is_map(memberships) -> - Enum.reduce(memberships, %{}, fn {step_id, group_id}, acc -> - normalized_step_id = to_string(step_id) - - if normalized_step_id == "" do - acc - else - normalized_group_id = - case group_id do - nil -> nil - "" -> nil - value when is_binary(value) -> value - value -> to_string(value) - end - - Map.put(acc, normalized_step_id, normalized_group_id) - end - end) - - _ -> - %{} - end - - payload = %{ - txn_id: txn_id, - groups: groups, - step_positions: step_positions, - group_id_by_step_id: group_id_by_step_id - } - - if is_integer(base_seq) do - Map.put(payload, :base_seq, base_seq) - else - payload - end - end - - defp commit_drag_layout_empty?(payload) do - groups = fetch_payload_value(payload, :groups) |> List.wrap() - - step_positions = - case fetch_payload_value(payload, :step_positions) do - value when is_map(value) -> value - _ -> %{} - end - - group_id_by_step_id = - case fetch_payload_value(payload, :group_id_by_step_id) do - value when is_map(value) -> value - _ -> %{} - end - - groups == [] and map_size(step_positions) == 0 and map_size(group_id_by_step_id) == 0 - end - - defp offset_position(position) do - normalized = normalize_position(position || %{}) - %{x: normalized.x + 50, y: normalized.y + 50} - end - - defp fetch_field(map, key) when is_map(map) do - Map.get(map, key) || Map.get(map, to_string(key)) - end - - defp fetch_field(_map, _key), do: nil - - defp apply_operation(socket, type, payload) do - operation = %{ - id: UUID.generate(), - type: type, - payload: payload, - user_id: socket.assigns.current_user_id, - client_seq: nil - } - - case Server.apply_operation(socket.assigns.workflow.id, operation) do - {:ok, _result} -> - {:noreply, push_undo_state(socket)} - - {:error, reason} -> - require Logger - Logger.warning("Operation failed: #{inspect(reason)}") - {:noreply, put_flash(socket, :error, "Operation failed")} - end - end - - defp parse_count(count) when is_integer(count) and count > 0, do: count - - defp parse_count(count) when is_binary(count) do - case Integer.parse(count) do - {value, _} when value > 0 -> value - _ -> 1 - end - end - - defp parse_count(_count), do: 1 - - defp parse_optional_non_negative_integer(value) when is_integer(value) and value >= 0, do: value - - defp parse_optional_non_negative_integer(value) when is_binary(value) do - case Integer.parse(value) do - {parsed, ""} when parsed >= 0 -> parsed - _ -> nil - end - end - - defp parse_optional_non_negative_integer(_value), do: nil - - defp normalize_optional_group_font_size(changes) when is_map(changes) do - case Map.get(changes, :font_size) || Map.get(changes, "font_size") do - nil -> - changes - - font_size -> - case parse_optional_non_negative_integer(font_size) do - nil -> - changes - |> Map.delete(:font_size) - |> Map.delete("font_size") - - parsed -> - changes - |> Map.delete("font_size") - |> Map.put(:font_size, parse_group_font_size(parsed)) - end - end - end - - defp parse_group_font_size(font_size) do - normalized = - case font_size do - value when is_integer(value) -> value - value when is_binary(value) -> parse_optional_non_negative_integer(value) - _ -> nil - end - - case normalized do - value when is_integer(value) -> - value - |> max(@group_name_font_size_min) - |> min(@group_name_font_size_max) - - _ -> - @group_name_font_size_default - end - end - - defp stale_seq?(nil, _current_seq), do: false - defp stale_seq?(incoming_seq, current_seq) when incoming_seq <= current_seq, do: true - defp stale_seq?(_incoming_seq, _current_seq), do: false - - defp maybe_assign_collab_seq(socket, nil), do: socket - - defp maybe_assign_collab_seq(socket, seq) when is_integer(seq) do - assign(socket, :collab_seq, max(socket.assigns.collab_seq, seq)) - end - - defp push_operation_ack(socket, operation, seq) do - payload = fetch_payload_value(operation, :payload) || %{} - raw_type = fetch_payload_value(operation, :type) - - type = - case raw_type do - value when is_atom(value) -> Atom.to_string(value) - value -> value - end - - push_event(socket, "workflow:operation_ack", %{ - seq: seq, - operation_id: fetch_payload_value(operation, :operation_id), - type: type, - txn_id: fetch_payload_value(payload, :txn_id), - base_seq: - payload - |> fetch_payload_value(:base_seq) - |> parse_optional_non_negative_integer() - }) - end - - defp push_undo_state(socket) do - case Server.get_undo_state(socket.assigns.workflow.id, socket.assigns.current_user_id) do - {:ok, undo_state} -> - push_event(socket, "workflow:undo_state", undo_state) - - _ -> - socket - end - end - - defp maybe_push_undo_state_for_operation(socket, operation) do - if operation.user_id == socket.assigns.current_user_id do - push_undo_state(socket) - else - socket - end - end - - defp refresh_workflow(socket) do - case Workflows.get_workflow_with_draft( - socket.assigns.current_scope, - socket.assigns.workflow.id - ) do - {:ok, workflow} -> assign(socket, :workflow, workflow) - {:error, _} -> socket - end - end - - defp webhook_listening?(socket) do - case socket.assigns.editor_state do - %EditorState{webhook_test: webhook_test} when not is_nil(webhook_test) -> true - _ -> false - end - end - - defp maybe_toggle_webhook_subscription(socket, %EditorState{} = editor_state) do - listening? = not is_nil(editor_state.webhook_test) - subscribed? = socket.assigns.webhook_execution_subscribed - - cond do - listening? and not subscribed? -> - case ExecutionPubSub.subscribe_workflow_executions( - socket.assigns.current_scope, - socket.assigns.workflow.id - ) do - :ok -> assign(socket, :webhook_execution_subscribed, true) - {:error, _} -> socket - end - - not listening? and subscribed? -> - ExecutionPubSub.unsubscribe_workflow_executions(socket.assigns.workflow.id) - assign(socket, :webhook_execution_subscribed, false) - - true -> - socket - end - end - - defp switch_to_execution(socket, %Execution{} = execution) do - socket = unsubscribe_execution(socket) - _ = subscribe_execution(socket.assigns.current_scope, execution.id) - - steps = - case socket.assigns.workflow.draft do - nil -> [] - draft -> draft.steps || [] - end - - step_executions = build_initial_step_executions(execution.id, steps) - - socket - |> assign(:execution, execution) - |> assign(:execution_id, execution.id) - |> assign(:step_executions, step_executions) - |> assign(:debug_execution_id, nil) - end - - defp start_preview_execution(socket) do - socket = unsubscribe_execution(socket) - - workflow = socket.assigns.workflow - scope = socket.assigns.current_scope - - {draft, editor_state} = - fetch_session_state( - workflow.id, - workflow.draft, - socket.assigns.editor_state - ) - - workflow = %{workflow | draft: draft} - - socket = - socket - |> assign(:workflow, workflow) - |> assign(:editor_state, editor_state) - - if is_nil(draft) do - {:error, "workflow draft missing", socket} - else - preview_draft = build_preview_draft(draft, editor_state) - pinned_outputs = editor_state.pinned_outputs || %{} - pinned_steps = Map.keys(pinned_outputs) - disabled_steps = MapSet.to_list(editor_state.disabled_steps || MapSet.new()) - trigger_data = find_trigger_data(preview_draft) - - attrs = %{ - workflow_id: workflow.id, - execution_type: :preview, - trigger: %{type: :manual, data: trigger_data}, - metadata: %{ - extras: - build_editor_execution_extras(%{ - preview: true, - pinned_steps: pinned_steps, - disabled_steps: disabled_steps - }) - } - } - - with {:ok, execution} <- Executions.create_execution(scope, attrs) do - try do - with :ok <- subscribe_execution(scope, execution.id), - {:ok, _pid} <- - start_execution_process(execution.id, - step_outputs: pinned_outputs, - source: preview_draft - ) do - step_executions = build_initial_step_executions(execution.id, preview_draft.steps) - - socket = - socket - |> assign(:execution, execution) - |> assign(:execution_id, execution.id) - |> assign(:step_executions, step_executions) - |> assign(:debug_execution_id, nil) - - {:ok, socket} - else - {:error, reason} -> - _ = Executions.update_execution_status(scope, execution, :failed, error: reason) - {:error, format_execution_error(reason), socket} - end - rescue - e -> - Logger.error("Crash during preview execution setup: #{inspect(e)}", - stacktrace: __STACKTRACE__ - ) - - _ = Executions.update_execution_status(scope, execution, :failed, error: e) - {:error, "Crash during setup: #{inspect(e)}", socket} - end - else - {:error, reason} -> - {:error, format_execution_error(reason), socket} - end - end - end - - defp start_partial_execution(socket, step_id) do - socket = unsubscribe_execution(socket) - - workflow = socket.assigns.workflow - scope = socket.assigns.current_scope - - {draft, editor_state} = - fetch_session_state( - workflow.id, - workflow.draft, - socket.assigns.editor_state - ) - - workflow = %{workflow | draft: draft} - - socket = - socket - |> assign(:workflow, workflow) - |> assign(:editor_state, editor_state) - - if is_nil(draft) do - {:error, "workflow draft missing", socket} - else - preview_draft = build_preview_draft(draft, editor_state) - - with {:ok, partial_draft} <- build_partial_draft(preview_draft, step_id), - {:ok, execution} <- - create_partial_execution(workflow.id, scope, partial_draft, step_id, editor_state) do - try do - with :ok <- subscribe_execution(scope, execution.id), - {:ok, _pid} <- - start_execution_process(execution.id, - step_outputs: editor_state.pinned_outputs || %{}, - source: partial_draft - ) do - step_executions = build_initial_step_executions(execution.id, partial_draft.steps) - - socket = - socket - |> assign(:execution, execution) - |> assign(:execution_id, execution.id) - |> assign(:step_executions, step_executions) - |> assign(:debug_execution_id, nil) - - {:ok, socket} - else - {:error, reason} -> - _ = Executions.update_execution_status(scope, execution, :failed, error: reason) - {:error, format_execution_error(reason), socket} - end - rescue - e -> - Logger.error("Crash during partial execution setup: #{inspect(e)}", - stacktrace: __STACKTRACE__ - ) - - _ = Executions.update_execution_status(scope, execution, :failed, error: e) - {:error, "Crash during setup: #{inspect(e)}", socket} - end - else - {:error, :step_not_found} -> - {:error, "step not found or disabled", socket} - - {:error, reason} -> - {:error, format_execution_error(reason), socket} - end - end - end - - defp find_trigger_data(draft) do - # Find the first manual_input step and extract its trigger_data config - manual_input_step = - Enum.find(draft.steps, fn step -> - step.type_id == "manual_input" - end) - - case manual_input_step do - %{config: %{"trigger_data" => raw_json}} when is_binary(raw_json) -> - case Jason.decode(raw_json) do - {:ok, data} when is_map(data) -> data - _ -> %{} - end - - %{config: %{"trigger_data" => data}} when is_map(data) -> - data - - _ -> - %{} - end - end - - defp create_partial_execution(workflow_id, scope, draft, step_id, %EditorState{} = editor_state) do - trigger_data = find_trigger_data(draft) - pinned_outputs = editor_state.pinned_outputs || %{} - pinned_steps = Map.keys(pinned_outputs) - disabled_steps = MapSet.to_list(editor_state.disabled_steps || MapSet.new()) - - attrs = %{ - workflow_id: workflow_id, - execution_type: :partial, - trigger: %{type: :manual, data: trigger_data}, - metadata: %{ - extras: - build_editor_execution_extras(%{ - partial: true, - target_step_id: step_id, - pinned_steps: pinned_steps, - disabled_steps: disabled_steps - }) - } - } - - Executions.create_execution(scope, attrs) - end - - defp build_preview_draft(draft, %EditorState{} = editor_state) do - disabled_steps = editor_state.disabled_steps || MapSet.new() - - steps = - Enum.reject(draft.steps, fn step -> - MapSet.member?(disabled_steps, step.id) - end) - - step_ids = MapSet.new(Enum.map(steps, & &1.id)) - - connections = - Enum.filter(draft.connections, fn conn -> - MapSet.member?(step_ids, conn.source_step_id) and - MapSet.member?(step_ids, conn.target_step_id) - end) - - %{draft | steps: steps, connections: connections} - end - - defp build_partial_draft(draft, step_id) do - steps = draft.steps || [] - connections = draft.connections || [] - graph = Fizz.Graph.from_workflow!(steps, connections, validate: false) - - if Fizz.Graph.has_vertex?(graph, step_id) do - upstream = Fizz.Graph.upstream(graph, step_id) - keep_ids = MapSet.new([step_id | upstream]) - - filtered_steps = Enum.filter(steps, &MapSet.member?(keep_ids, &1.id)) - - filtered_connections = - Enum.filter(connections, fn conn -> - MapSet.member?(keep_ids, conn.source_step_id) and - MapSet.member?(keep_ids, conn.target_step_id) - end) - - {:ok, %{draft | steps: filtered_steps, connections: filtered_connections}} - else - {:error, :step_not_found} - end - end - - defp build_initial_step_executions(execution_id, steps) do - now = DateTime.utc_now() - - Enum.map(steps, fn step -> - %{ - id: "#{execution_id}:#{step.id}", - execution_id: execution_id, - step_id: step.id, - step_type_id: step.type_id, - status: :pending, - attempt: 1, - input_data: nil, - output_data: nil, - output_item_count: nil, - error: nil, - queued_at: nil, - started_at: nil, - completed_at: nil, - duration_us: nil, - metadata: %{}, - inserted_at: now - } - end) - end - - defp build_editor_execution_extras(extra_attrs) when is_map(extra_attrs) do - Map.merge(%{request: build_mock_request()}, extra_attrs) - end - - defp build_mock_request do - %{ - "request_id" => "mock-request-" <> UUID.generate(), - "headers" => %{"user-agent" => "Fizz Editor (Preview)"}, - "body" => %{} - } - end - - defp subscribe_execution(scope, execution_id) do - case ExecutionPubSub.subscribe_execution(scope, execution_id) do - :ok -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp unsubscribe_execution(socket) do - case socket.assigns.execution_id do - nil -> - socket - - execution_id -> - ExecutionPubSub.unsubscribe_execution(execution_id) - - socket - |> assign(:execution_id, nil) - |> assign(:execution, nil) - |> assign(:step_executions, []) - end - end - - defp start_execution_process(execution_id, opts) do - case ExecutionSupervisor.start_execution(execution_id, opts) do - {:ok, pid} -> {:ok, pid} - {:error, {:already_started, pid}} -> {:ok, pid} - {:error, reason} -> {:error, reason} - end - end - - defp maybe_stop_execution_process(execution_id) do - case ExecutionSupervisor.get_execution_pid(execution_id) do - {:ok, pid} -> - Process.exit(pid, :shutdown) - :ok - - {:error, _reason} -> - :ok - end - end - - defp refresh_execution_from_event(socket, %{execution_id: execution_id}) do - case Executions.get_execution(socket.assigns.current_scope, execution_id) do - {:ok, execution} -> assign(socket, :execution, execution) - {:error, _} -> socket - end - end - - defp handle_execution_lifecycle_event(socket, %{event_name: event_name} = event) - when event_name in @execution_lifecycle_events do - socket = - case event_name do - :execution_started -> - maybe_switch_to_webhook_execution(socket, event) - - :execution_failed -> - socket - |> maybe_switch_to_webhook_execution(event) - |> maybe_put_execution_failed_flash(event) - - _ -> - socket - end - - maybe_refresh_current_execution(socket, event) - end - - defp maybe_refresh_current_execution(socket, %{execution_id: execution_id} = event) do - if execution_id == socket.assigns.execution_id do - refresh_execution_from_event(socket, event) - else - socket - end - end - - defp maybe_switch_to_webhook_execution( - socket, - %{execution_id: execution_id, workflow_id: workflow_id} - ) do - with true <- webhook_listening?(socket), - true <- workflow_id == socket.assigns.workflow.id, - false <- execution_id == socket.assigns.execution_id, - {:ok, execution} <- Executions.get_execution(socket.assigns.current_scope, execution_id), - true <- webhook_preview_execution?(execution) do - switch_to_execution(socket, execution) - else - _ -> socket - end - end - - defp maybe_switch_to_webhook_execution(socket, _event), do: socket - - defp webhook_preview_execution?(%Execution{ - execution_type: :preview, - trigger: %Execution.Trigger{type: :webhook} - }), - do: true - - defp webhook_preview_execution?(_execution), do: false - - defp maybe_put_execution_failed_flash( - socket, - %{event_name: :execution_failed, execution_id: execution_id, payload: payload} - ) do - if execution_id == socket.assigns.execution_id do - error = fetch_payload_value(payload, :error) || payload - put_flash(socket, :error, "Execution failed: #{format_error_message(error)}") - else - socket - end - end - - defp maybe_put_execution_failed_flash(socket, _event), do: socket - - defp update_step_executions(socket, event_name, payload) do - step_executions = - EditStepProjection.apply_event( - socket.assigns.step_executions, - socket.assigns.execution_id, - event_name, - payload - ) - - assign(socket, :step_executions, step_executions) - end - - defp maybe_put_group_id(payload, nil), do: payload - - defp maybe_put_group_id(payload, group_id) when is_binary(group_id) do - Map.put(payload, :group_id, group_id) - end - - defp maybe_put_group_id(payload, _group_id), do: payload - - defp maybe_put_step_size(payload, nil), do: payload - - defp maybe_put_step_size(payload, %{width: width, height: height} = step_size) - when (is_integer(width) or is_float(width)) and - (is_integer(height) or is_float(height)) do - Map.put(payload, :step_size, step_size) - end - - defp maybe_put_step_size(payload, _step_size), do: payload - - defp parse_step_size(params) when is_map(params) do - params - |> fetch_step_size_payload() - |> normalize_step_size() - end - - defp parse_step_size(_params), do: nil - - defp fetch_step_size_payload(params) do - Map.get(params, "step_size") || - Map.get(params, :step_size) || - Map.get(params, "stepSize") || - Map.get(params, :stepSize) - end - - defp normalize_step_size(%{} = size_payload) do - with {:ok, width} <- normalize_step_dimension(step_size_value(size_payload, :width)), - {:ok, height} <- normalize_step_dimension(step_size_value(size_payload, :height)) do - %{width: width, height: height} - else - _ -> nil - end - end - - defp normalize_step_size(_size_payload), do: nil - - defp step_size_value(payload, key) when is_map(payload) do - Map.get(payload, key) || Map.get(payload, Atom.to_string(key)) - end - - defp normalize_step_dimension(value) when is_integer(value) and value > 0, do: {:ok, value} - defp normalize_step_dimension(value) when is_float(value) and value > 0, do: {:ok, value} - - defp normalize_step_dimension(value) when is_binary(value) do - case Float.parse(String.trim(value)) do - {parsed, _rest} when parsed > 0 -> {:ok, parsed} - _ -> :error - end - end - - defp normalize_step_dimension(_value), do: :error - - defp build_add_step_auto_connect_connection(params, new_step_id) do - auto_connect = - Map.get(params, "auto_connect") || - Map.get(params, :auto_connect) || - Map.get(params, "autoConnect") || - Map.get(params, :autoConnect) - - case auto_connect do - %{} = auto_connect -> - fixed_source_step_id = - auto_connect - |> auto_connect_value(:source_step_id) - |> normalize_non_empty_string() - - fixed_target_step_id = - auto_connect - |> auto_connect_value(:target_step_id) - |> normalize_non_empty_string() - - source_output = - auto_connect - |> auto_connect_value(:source_output) - |> normalize_connection_port("main") - - target_input = - auto_connect - |> auto_connect_value(:target_input) - |> normalize_connection_port("main") - - cond do - is_binary(fixed_source_step_id) and is_binary(fixed_target_step_id) -> - nil - - is_binary(fixed_source_step_id) -> - %{ - id: UUID.generate(), - source_step_id: fixed_source_step_id, - source_output: source_output, - target_step_id: new_step_id, - target_input: target_input - } - - is_binary(fixed_target_step_id) -> - %{ - id: UUID.generate(), - source_step_id: new_step_id, - source_output: source_output, - target_step_id: fixed_target_step_id, - target_input: target_input - } - - true -> - nil - end - - _ -> - nil - end - end - - defp auto_connect_value(map, :source_step_id) when is_map(map) do - Map.get(map, "source_step_id") || - Map.get(map, :source_step_id) || - Map.get(map, "sourceStepId") || - Map.get(map, :sourceStepId) - end - - defp auto_connect_value(map, :target_step_id) when is_map(map) do - Map.get(map, "target_step_id") || - Map.get(map, :target_step_id) || - Map.get(map, "targetStepId") || - Map.get(map, :targetStepId) - end - - defp auto_connect_value(map, :source_output) when is_map(map) do - Map.get(map, "source_output") || - Map.get(map, :source_output) || - Map.get(map, "sourceOutput") || - Map.get(map, :sourceOutput) - end - - defp auto_connect_value(map, :target_input) when is_map(map) do - Map.get(map, "target_input") || - Map.get(map, :target_input) || - Map.get(map, "targetInput") || - Map.get(map, :targetInput) - end - - defp normalize_non_empty_string(nil), do: nil - - defp normalize_non_empty_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - normalized -> normalized - end - end - - defp normalize_non_empty_string(value) when is_atom(value) do - value - |> Atom.to_string() - |> normalize_non_empty_string() - end - - defp normalize_non_empty_string(_value), do: nil - - defp normalize_connection_port(value, fallback) when is_binary(value) do - case String.trim(value) do - "" -> fallback - normalized -> normalized - end - end - - defp normalize_connection_port(nil, fallback), do: fallback - - defp normalize_connection_port(value, fallback) when is_atom(value) do - value - |> Atom.to_string() - |> normalize_connection_port(fallback) - end - - defp normalize_connection_port(_value, fallback), do: fallback - - defp step_exists_in_session_draft?(workflow_id, step_id) do - case Server.get_sync_state(workflow_id) do - {:ok, %{draft: draft}} -> - draft.steps - |> List.wrap() - |> Enum.any?(fn step -> fetch_field(step, :id) == step_id end) - - _ -> - false - end - end - - defp fetch_payload_value(payload, key) when is_map(payload) do - string_key = Atom.to_string(key) - - cond do - Map.has_key?(payload, key) -> Map.get(payload, key) - Map.has_key?(payload, string_key) -> Map.get(payload, string_key) - true -> nil - end - end - - defp fetch_payload_value(_payload, _key), do: nil - - defp payload_has_key?(payload, key) when is_map(payload) do - Map.has_key?(payload, key) || Map.has_key?(payload, Atom.to_string(key)) - end - - defp payload_has_key?(_payload, _key), do: false - - defp parse_item_index(payload) do - case fetch_payload_value(payload, :item_index) do - nil -> - nil - - index when is_integer(index) -> - index - - index when is_binary(index) -> - case Integer.parse(index) do - {parsed, _} -> parsed - :error -> nil - end - - _ -> - nil - end - end - - defp resolve_pin_output(step_executions, step_id, item_index) do - candidates = - step_executions - |> Enum.filter(&step_execution_matches?(&1, step_id, item_index)) - |> then(fn matches -> - if matches == [] and not is_nil(item_index) do - Enum.filter(step_executions, &step_execution_matches?(&1, step_id, nil)) - else - matches - end - end) - - case pick_latest_execution(candidates) do - nil -> nil - execution -> Map.get(execution, :output_data) - end - end - - defp step_execution_matches?(execution, step_id, nil) do - Map.get(execution, :step_id) == step_id - end - - defp step_execution_matches?(execution, step_id, item_index) do - Map.get(execution, :step_id) == step_id and Map.get(execution, :item_index) == item_index - end - - defp pick_latest_execution([]), do: nil - - defp pick_latest_execution(executions) do - completed = - Enum.filter(executions, fn execution -> - status = Map.get(execution, :status) - status in [:completed, "completed"] - end) - - candidates = if completed == [], do: executions, else: completed - - Enum.max_by(candidates, &execution_timestamp/1, fn -> nil end) - end - - defp execution_timestamp(execution) do - completed_at = Map.get(execution, :completed_at) - started_at = Map.get(execution, :started_at) - inserted_at = Map.get(execution, :inserted_at) - - timestamp_from(completed_at) || timestamp_from(started_at) || timestamp_from(inserted_at) || 0 - end - - defp timestamp_from(nil), do: nil - - defp timestamp_from(%DateTime{} = datetime) do - DateTime.to_unix(datetime, :millisecond) - end - - defp timestamp_from(%NaiveDateTime{} = datetime) do - datetime - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_unix(:millisecond) - end - - defp timestamp_from(value) when is_binary(value) do - case DateTime.from_iso8601(value) do - {:ok, datetime, _offset} -> - DateTime.to_unix(datetime, :millisecond) - - _ -> - case NaiveDateTime.from_iso8601(value) do - {:ok, datetime} -> - datetime - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_unix(:millisecond) - - _ -> - nil - end - end - end - - defp timestamp_from(_value), do: nil - - defp lookup_resolver(socket, node_id, field_key) do - steps = socket.assigns.workflow.draft.steps || [] - - with {:ok, step} <- find_step(steps, node_id), - {:ok, type} <- StepRegistry.get(step.type_id), - {:ok, resolver} <- extract_resolver(type.config_schema, field_key) do - {:ok, resolver} - end - end - - defp find_step(steps, node_id) do - case Enum.find(steps, &(&1.id == node_id)) do - nil -> {:error, :step_not_found} - step -> {:ok, step} - end - end - - defp extract_resolver(config_schema, field_key) do - case get_in(config_schema, ["properties", field_key, "ui", "resolver"]) do - nil -> {:error, :no_resolver} - module when is_atom(module) -> {:ok, module} - _other -> {:error, :invalid_resolver} - end - end - - defp format_test_webhook_error(:webhook_not_found), do: "webhook trigger not found" - defp format_test_webhook_error(:not_found), do: "edit session not running" - defp format_test_webhook_error(reason), do: inspect(reason) - - defp format_execution_error(:access_denied), do: "access denied" - defp format_execution_error(:workflow_not_found), do: "workflow not found" - defp format_execution_error(:workflow_not_published), do: "workflow not published" - defp format_execution_error(:unauthorized), do: "access denied" - defp format_execution_error(:not_found), do: "execution not found" - - defp format_execution_error(%Ecto.Changeset{} = changeset) do - error = - changeset - |> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end) - |> inspect() - - "invalid execution: #{error}" - end - - defp format_execution_error(reason), do: inspect(reason) - - defp handle_presence_diff(socket, _diff) do - # Fetch latest full presence list - presences = PresenceFormatter.format(Presence.list_users(socket.assigns.workflow.id)) - {:noreply, assign(socket, :presences, presences)} - end - - defp deserialize_editor_state(nil, workflow_id) do - %EditorState{workflow_id: workflow_id} - end - - defp deserialize_editor_state(state, workflow_id) when is_map(state) do - %EditorState{ - workflow_id: workflow_id, - pinned_outputs: state[:pinned_outputs] || state["pinned_outputs"] || %{}, - disabled_steps: MapSet.new(state[:disabled_steps] || state["disabled_steps"] || []), - step_locks: state[:step_locks] || state["step_locks"] || %{}, - webhook_test: state[:webhook_test] || state["webhook_test"] - } - end - - defp fetch_session_state(workflow_id, fallback_draft, fallback_editor_state) do - try do - case Server.get_sync_state(workflow_id) do - {:ok, %{type: :full_sync, draft: draft, editor_state: editor_state}} -> - draft = draft || fallback_draft - {draft, deserialize_editor_state(editor_state, workflow_id)} - - _ -> - {fallback_draft, fallback_editor_state} - end - catch - :exit, _ -> {fallback_draft, fallback_editor_state} - end - end - - defp fetch_undo_state(workflow_id, user_id) do - case Server.get_undo_state(workflow_id, user_id) do - {:ok, undo_state} -> undo_state - _ -> nil - end - end - - defp format_error_message(error) when is_binary(error), do: error - defp format_error_message(%{message: message}), do: message - defp format_error_message(%{"message" => message}), do: message - defp format_error_message(error), do: inspect(error) -end diff --git a/lib/fizz_web/live/workflow_live/edit/command.ex b/lib/fizz_web/live/workflow_live/edit/command.ex deleted file mode 100644 index 83fb679..0000000 --- a/lib/fizz_web/live/workflow_live/edit/command.ex +++ /dev/null @@ -1,54 +0,0 @@ -defmodule FizzWeb.WorkflowLive.Edit.Command do - @moduledoc false - - @commands MapSet.new([ - "add_step", - "duplicate_steps", - "move_step", - "move_steps", - "tidy_layout", - "update_step", - "remove_step", - "add_group", - "update_group", - "remove_group", - "set_group_membership", - "commit_drag_layout", - "add_connection", - "remove_connection", - "undo", - "redo", - "navigate_revisions", - "pin_output", - "unpin_output", - "disable_step", - "enable_step", - "mouse_move", - "selection_changed", - "save_workflow", - "publish_workflow", - "run_test", - "run_node", - "toggle_webhook_test", - "cancel_execution", - "preview_expression" - ]) - - @spec parse(map()) :: {:ok, String.t(), map()} | {:error, :invalid} - def parse(%{"type" => type, "payload" => payload}) - when is_binary(type) and is_map(payload) do - {:ok, type, payload} - end - - def parse(%{type: type, payload: payload}) when is_binary(type) and is_map(payload) do - {:ok, type, payload} - end - - def parse(%{"type" => type}) when is_binary(type), do: {:ok, type, %{}} - def parse(%{type: type}) when is_binary(type), do: {:ok, type, %{}} - def parse(_params), do: {:error, :invalid} - - @spec valid?(String.t()) :: boolean() - def valid?(command) when is_binary(command), do: MapSet.member?(@commands, command) - def valid?(_command), do: false -end diff --git a/lib/fizz_web/live/workflow_live/edit/edit_step_projection.ex b/lib/fizz_web/live/workflow_live/edit/edit_step_projection.ex deleted file mode 100644 index e1ff516..0000000 --- a/lib/fizz_web/live/workflow_live/edit/edit_step_projection.ex +++ /dev/null @@ -1,242 +0,0 @@ -defmodule FizzWeb.WorkflowLive.Edit.EditStepProjection do - @moduledoc false - - @step_lifecycle_events [ - :step_started, - :step_completed, - :step_failed, - :step_skipped, - :step_cancelled - ] - - @spec apply_event([map()], String.t() | nil, atom(), map()) :: [map()] - def apply_event(step_executions, execution_id, event_name, payload) - when is_list(step_executions) and event_name in @step_lifecycle_events and is_map(payload) do - case normalize_step_payload(payload, execution_id, event_name) do - nil -> step_executions - step_execution -> upsert_step_execution(step_executions, step_execution) - end - end - - def apply_event(step_executions, _execution_id, _event_name, _payload), do: step_executions - - defp normalize_step_payload(payload, execution_id, event_name) do - step_id = fetch_payload_value(payload, :step_id) - payload_execution_id = fetch_payload_value(payload, :execution_id) || execution_id - item_index = fetch_payload_value(payload, :item_index) - attempt = parse_attempt(fetch_payload_value(payload, :attempt)) - - if step_id && payload_execution_id && payload_execution_id == execution_id do - %{ - id: - fetch_payload_value(payload, :id) || - step_execution_id(payload_execution_id, step_id, item_index, attempt), - execution_id: payload_execution_id, - step_id: step_id, - step_type_id: fetch_payload_value(payload, :step_type_id), - status: fetch_payload_value(payload, :status) || default_step_status(event_name), - input_data: fetch_payload_value(payload, :input_data), - output_data: fetch_payload_value(payload, :output_data), - output_item_count: fetch_payload_value(payload, :output_item_count), - item_index: item_index, - items_total: fetch_payload_value(payload, :items_total), - error: fetch_payload_value(payload, :error), - attempt: attempt, - retry_of_id: fetch_payload_value(payload, :retry_of_id), - queued_at: fetch_payload_value(payload, :queued_at), - started_at: - payload - |> fetch_payload_value(:started_at) - |> normalize_timestamp(), - completed_at: - payload - |> fetch_payload_value(:completed_at) - |> normalize_timestamp(), - duration_us: fetch_payload_value(payload, :duration_us), - metadata: fetch_payload_value(payload, :metadata) - } - end - end - - defp parse_attempt(attempt) when is_integer(attempt) and attempt > 0, do: attempt - - defp parse_attempt(attempt) when is_binary(attempt) do - case Integer.parse(attempt) do - {parsed, _rest} when parsed > 0 -> parsed - _ -> 1 - end - end - - defp parse_attempt(_attempt), do: 1 - - defp normalize_timestamp(nil), do: nil - defp normalize_timestamp(%DateTime{} = value), do: DateTime.to_iso8601(value) - defp normalize_timestamp(%NaiveDateTime{} = value), do: NaiveDateTime.to_iso8601(value) - defp normalize_timestamp(value) when is_binary(value), do: value - defp normalize_timestamp(value) when is_map(value), do: normalize_sanitized_datetime(value) - defp normalize_timestamp(_value), do: nil - - defp normalize_sanitized_datetime(value) when is_map(value) do - with {:ok, year} <- fetch_integer(value, :year), - {:ok, month} <- fetch_integer(value, :month), - {:ok, day} <- fetch_integer(value, :day), - {:ok, hour} <- fetch_integer(value, :hour), - {:ok, minute} <- fetch_integer(value, :minute), - {:ok, second} <- fetch_integer(value, :second), - {:ok, date} <- Date.new(year, month, day), - {:ok, time} <- Time.new(hour, minute, second, normalize_microsecond(value)), - {:ok, naive_datetime} <- NaiveDateTime.new(date, time), - {:ok, datetime} <- DateTime.from_naive(naive_datetime, "Etc/UTC") do - value - |> total_offset_seconds() - |> then(&DateTime.add(datetime, -&1, :second)) - |> DateTime.to_iso8601() - else - _ -> nil - end - end - - defp fetch_integer(map, key) when is_map(map) do - case fetch_payload_value(map, key) do - value when is_integer(value) -> {:ok, value} - _ -> :error - end - end - - defp normalize_microsecond(map) when is_map(map) do - case fetch_payload_value(map, :microsecond) do - [value, precision] when is_integer(value) and is_integer(precision) -> {value, precision} - {value, precision} when is_integer(value) and is_integer(precision) -> {value, precision} - value when is_integer(value) -> {value, 6} - _ -> {0, 0} - end - end - - defp total_offset_seconds(map) when is_map(map) do - map - |> fetch_payload_value(:utc_offset) - |> normalize_offset_seconds() - |> Kernel.+( - map - |> fetch_payload_value(:std_offset) - |> normalize_offset_seconds() - ) - end - - defp normalize_offset_seconds(value) when is_integer(value), do: value - defp normalize_offset_seconds(_value), do: 0 - - defp fetch_payload_value(payload, key) when is_map(payload) do - string_key = Atom.to_string(key) - - cond do - Map.has_key?(payload, key) -> Map.get(payload, key) - Map.has_key?(payload, string_key) -> Map.get(payload, string_key) - true -> nil - end - end - - defp default_step_status(:step_started), do: :running - defp default_step_status(:step_failed), do: :failed - defp default_step_status(:step_completed), do: :completed - defp default_step_status(:step_skipped), do: :skipped - defp default_step_status(:step_cancelled), do: :cancelled - defp default_step_status(_event_name), do: :pending - - defp step_execution_id(execution_id, step_id, nil, attempt) do - "#{execution_id}:#{step_id}:#{attempt}" - end - - defp step_execution_id(execution_id, step_id, item_index, attempt) do - "#{execution_id}:#{step_id}:#{item_index}:#{attempt}" - end - - defp upsert_step_execution(step_executions, step_execution) do - step_id = Map.get(step_execution, :step_id) - item_index = Map.get(step_execution, :item_index) - attempt = Map.get(step_execution, :attempt) || 1 - - step_executions = - if is_nil(item_index) do - step_executions - else - Enum.reject(step_executions, fn existing -> - Map.get(existing, :step_id) == step_id and - is_nil(Map.get(existing, :item_index)) and - replaceable_summary_step_execution?(existing) - end) - end - - case Enum.find_index(step_executions, fn existing -> - Map.get(existing, :step_id) == step_id and - Map.get(existing, :item_index) == item_index and - (Map.get(existing, :attempt) || 1) == attempt - end) do - nil -> - step_executions ++ [step_execution] - - index -> - existing = Enum.at(step_executions, index) - resolved_status = resolve_step_status(existing, step_execution) - - updated = - Enum.reduce(step_execution, existing, fn {key, value}, acc -> - if key == :status or is_nil(value) do - acc - else - Map.put(acc, key, value) - end - end) - |> Map.put(:status, resolved_status) - - List.replace_at(step_executions, index, updated) - end - end - - defp resolve_step_status(existing, incoming) do - existing_status = Map.get(existing, :status) - incoming_status = Map.get(incoming, :status) - existing_rank = step_status_rank(existing_status) - incoming_rank = step_status_rank(incoming_status) - - cond do - is_nil(existing_status) -> incoming_status - is_nil(incoming_status) -> existing_status - incoming_rank < existing_rank -> existing_status - true -> incoming_status - end - end - - defp replaceable_summary_step_execution?(step_execution) do - case Map.get(step_execution, :status) do - nil -> true - :pending -> true - "pending" -> true - :queued -> true - "queued" -> true - :running -> true - "running" -> true - _ -> false - end - end - - defp step_status_rank(status) do - case status do - :pending -> 0 - "pending" -> 0 - :queued -> 1 - "queued" -> 1 - :running -> 2 - "running" -> 2 - :completed -> 3 - "completed" -> 3 - :skipped -> 3 - "skipped" -> 3 - :cancelled -> 4 - "cancelled" -> 4 - :failed -> 5 - "failed" -> 5 - _ -> -1 - end - end -end diff --git a/lib/fizz_web/live/workflow_live/edit/expression_preview.ex b/lib/fizz_web/live/workflow_live/edit/expression_preview.ex deleted file mode 100644 index 0d7adeb..0000000 --- a/lib/fizz_web/live/workflow_live/edit/expression_preview.ex +++ /dev/null @@ -1,73 +0,0 @@ -defmodule FizzWeb.WorkflowLive.Edit.ExpressionPreview do - @moduledoc false - - alias Fizz.Runtime.Expression - - @spec evaluate(map(), String.t(), String.t()) :: term() - def evaluate(context, template, step_id) when is_binary(template) and is_binary(step_id) do - cond do - not Expression.contains_expression?(template) -> - template - - is_nil(Map.get(context, :execution)) -> - "Run a test to see preview results" - - true -> - do_evaluate(context, template, step_id) - end - end - - def evaluate(_context, template, _step_id), do: template - - defp do_evaluate(context, template, step_id) do - execution = Map.get(context, :execution) - step_executions = Map.get(context, :step_executions, []) - editor_state = Map.get(context, :editor_state, %{}) - draft = context |> Map.get(:workflow, %{}) |> Map.get(:draft) - - step_outputs = - step_executions - |> Map.new(fn step_execution -> - {Map.get(step_execution, :step_id), Map.get(step_execution, :output_data)} - end) - |> Map.merge(Map.get(editor_state, :pinned_outputs, %{})) - |> filter_to_upstream(draft, step_id) - - current_input = - step_executions - |> Enum.find(fn step_execution -> Map.get(step_execution, :step_id) == step_id end) - |> then(fn - nil -> nil - step_execution -> Map.get(step_execution, :input_data) - end) - - vars = Expression.Context.build(execution, step_outputs, current_input) - - case Expression.evaluate_with_vars(template, vars) do - {:ok, value} -> value_to_display_string(value) - {:error, reason} -> Map.put(reason, :text, template) - end - end - - defp filter_to_upstream(step_outputs, nil, _step_id), do: step_outputs - - defp filter_to_upstream(step_outputs, draft, step_id) when is_map(draft) do - graph = Fizz.Graph.from_workflow!(draft.steps || [], draft.connections || [], validate: false) - upstream_ids = Fizz.Graph.upstream(graph, step_id) - Map.take(step_outputs, upstream_ids) - rescue - _ -> step_outputs - end - - defp filter_to_upstream(step_outputs, _draft, _step_id), do: step_outputs - - defp value_to_display_string(value) when is_binary(value), do: value - defp value_to_display_string(value) when is_number(value), do: to_string(value) - defp value_to_display_string(value) when is_atom(value), do: Atom.to_string(value) - - defp value_to_display_string(value) when is_list(value) or is_map(value) do - inspect(value, limit: :infinity, printable_limit: :infinity) - end - - defp value_to_display_string(value), do: inspect(value) -end diff --git a/lib/fizz_web/live/workflow_live/edit/presence_formatter.ex b/lib/fizz_web/live/workflow_live/edit/presence_formatter.ex deleted file mode 100644 index 9475c4b..0000000 --- a/lib/fizz_web/live/workflow_live/edit/presence_formatter.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule FizzWeb.WorkflowLive.Edit.PresenceFormatter do - @moduledoc false - - @spec format(map()) :: [map()] - def format(presence_list) when is_map(presence_list) do - Enum.map(presence_list, fn {user_id, %{metas: metas}} -> - meta = List.first(metas) || %{} - - %{ - user: %{ - id: user_id, - name: get_in(meta, [:user, :name]), - email: get_in(meta, [:user, :email]) - }, - cursor: meta[:cursor], - dragging_steps: meta[:dragging_steps], - dragging_groups: meta[:dragging_groups], - selected_steps: meta[:selected_steps] || [], - focused_step: meta[:focused_step] - } - end) - end - - def format(_presence_list), do: [] -end diff --git a/lib/fizz_web/live/workflow_live/index.ex b/lib/fizz_web/live/workflow_live/index.ex index 85c4277..10bae24 100644 --- a/lib/fizz_web/live/workflow_live/index.ex +++ b/lib/fizz_web/live/workflow_live/index.ex @@ -52,7 +52,7 @@ defmodule FizzWeb.WorkflowLive.Index do {:noreply, socket |> put_flash(:info, "Workflow created") - |> push_navigate(to: Paths.workflow_edit_path(scope, workflow.id))} + |> push_navigate(to: Paths.workflow_show_path(scope, workflow.id))} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Failed to create workflow")} diff --git a/lib/fizz_web/live/workflow_live/paths.ex b/lib/fizz_web/live/workflow_live/paths.ex index c055f24..fc992fa 100644 --- a/lib/fizz_web/live/workflow_live/paths.ex +++ b/lib/fizz_web/live/workflow_live/paths.ex @@ -8,24 +8,6 @@ defmodule FizzWeb.WorkflowLive.Paths do def workflow_show_path(%{workspace: %{id: workspace_id}}, workflow_id), do: ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}" - def workflow_edit_path(%{workspace: %{id: workspace_id}}, workflow_id), - do: ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/edit" - - def workflow_edit_path(%{workspace: %{id: workspace_id}}, workflow_id, debug_execution_id), - do: - ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/edit?debug_execution_id=#{debug_execution_id}" - - def workflow_revisions_path(%{workspace: %{id: workspace_id}}, workflow_id), - do: ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/revisions" - - def workflow_revisions_path(%{workspace: %{id: workspace_id}}, workflow_id, %{undo: depth}), - do: ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/revisions?undo=#{depth}" - - def workflow_revisions_path(%{workspace: %{id: workspace_id}}, workflow_id, %{ - version: version_id - }), - do: ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/revisions?version=#{version_id}" - def execution_show_path(%{workspace: %{id: workspace_id}}, workflow_id, execution_id), do: ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/execution/#{execution_id}" end diff --git a/lib/fizz_web/live/workflow_live/revision.ex b/lib/fizz_web/live/workflow_live/revision.ex deleted file mode 100644 index 8563df1..0000000 --- a/lib/fizz_web/live/workflow_live/revision.ex +++ /dev/null @@ -1,457 +0,0 @@ -defmodule FizzWeb.WorkflowLive.Revision do - use FizzWeb, :live_view - - alias Ecto.UUID - alias Fizz.Accounts - alias Fizz.Collaboration.EditSession.{Server, Supervisor} - alias Fizz.Workflows - alias Fizz.Steps - alias FizzWeb.WorkflowLive.Paths - require Logger - - @group_name_font_size_default 14 - @group_name_font_size_min 10 - @group_name_font_size_max 32 - - @impl true - def mount(%{"workspace_id" => workspace_id, "id" => workflow_id}, _session, socket) do - with {:ok, scope} <- - Accounts.build_scope_for_workspace(socket.assigns.current_scope, workspace_id), - %{id: user_id} = _user <- scope.user, - {:ok, workflow} <- Workflows.get_workflow_with_draft(scope, workflow_id), - {:ok, _pid} <- Supervisor.ensure_session(scope, workflow.id) do - socket = - socket - |> assign(:current_scope, scope) - |> assign(:page_title, "Revisions · #{workflow.name}") - |> assign(:workflow, workflow) - |> assign(:draft, workflow.draft) - |> assign(:step_types, Steps.list_types()) - |> assign( - :versions, - serialize_versions(Workflows.list_workflow_versions(scope, workflow)) - ) - |> assign(:undo_stack, fetch_undo_stack(workflow.id, user_id)) - |> assign(:revision, %{kind: "current", label: "Current Draft"}) - |> assign(:editor_state, fetch_editor_state(workflow.id)) - |> assign(:current_user_id, user_id) - - {:ok, socket, layout: false} - else - {:error, :not_found} -> - {:ok, - socket - |> put_flash(:error, "Workflow not found") - |> redirect(to: ~p"/workspaces")} - - {:error, :unauthorized} -> - {:ok, - socket - |> put_flash(:error, "You do not have permission to view this workflow") - |> redirect(to: ~p"/workspaces")} - - {:error, reason} -> - Logger.warning("Revision viewer mount failed: #{inspect(reason)}") - - {:ok, - socket - |> put_flash(:error, "Unable to load revisions") - |> redirect(to: ~p"/workspaces")} - - _reason -> - {:ok, - socket - |> put_flash(:error, "Unable to load revisions") - |> redirect(to: ~p"/workspaces")} - end - end - - @impl true - def handle_params(params, _uri, socket) do - {:noreply, load_revision(socket, params)} - end - - @impl true - def render(assigns) do - ~H""" - -
- <.vue - v-component="RevisionViewer" - v-ssr={false} - v-socket={@socket} - workflow={@workflow} - draft={@draft} - revision={@revision} - versions={@versions} - undoStack={@undo_stack} - stepTypes={@step_types} - editorState={@editor_state} - v-on:select_revision={JS.push("select_revision")} - v-on:apply_revision={JS.push("apply_revision")} - v-on:navigate_back={JS.push("navigate_back")} - /> -
-
- """ - end - - @impl true - def handle_event("select_revision", %{"kind" => "current"}, socket) do - workflow = socket.assigns.workflow - scope = socket.assigns.current_scope - {:noreply, push_patch(socket, to: Paths.workflow_revisions_path(scope, workflow.id))} - end - - def handle_event("select_revision", %{"kind" => "undo", "depth" => depth}, socket) do - depth = parse_count(depth) - workflow = socket.assigns.workflow - scope = socket.assigns.current_scope - - {:noreply, - push_patch(socket, to: Paths.workflow_revisions_path(scope, workflow.id, %{undo: depth}))} - end - - def handle_event("select_revision", %{"kind" => "version", "id" => version_id}, socket) do - workflow = socket.assigns.workflow - scope = socket.assigns.current_scope - - {:noreply, - push_patch( - socket, - to: Paths.workflow_revisions_path(scope, workflow.id, %{version: version_id}) - )} - end - - @impl true - def handle_event("apply_revision", _params, socket) do - case apply_current_revision(socket) do - {:ok, _} -> - workflow = socket.assigns.workflow - scope = socket.assigns.current_scope - {:noreply, push_navigate(socket, to: Paths.workflow_edit_path(scope, workflow.id))} - - {:error, reason} -> - Logger.warning("Revision apply failed: #{inspect(reason)}") - {:noreply, put_flash(socket, :error, "Unable to apply revision")} - end - end - - def handle_event("navigate_back", _params, socket) do - workflow = socket.assigns.workflow - scope = socket.assigns.current_scope - {:noreply, push_navigate(socket, to: Paths.workflow_edit_path(scope, workflow.id))} - end - - defp load_revision(socket, %{"undo" => depth}) do - depth = parse_count(depth) - workflow_id = socket.assigns.workflow.id - user_id = socket.assigns.current_user_id - - case Server.preview_undo(workflow_id, user_id, depth) do - {:ok, draft} -> - label = - socket.assigns.undo_stack - |> Enum.find_value(fn entry -> - if entry.depth == depth, do: entry.label - end) - |> case do - nil -> "Undo #{depth}" - value -> value - end - - socket - |> assign(:draft, draft) - |> assign(:revision, %{kind: "undo", depth: depth, label: label}) - - {:error, reason} -> - Logger.warning("Revision preview failed: #{inspect(reason)}") - - socket - |> put_flash(:error, "Could not load undo preview") - |> assign(:draft, socket.assigns.workflow.draft) - |> assign(:revision, %{kind: "current", label: "Current Draft"}) - end - end - - defp load_revision(socket, %{"version" => version_id}) do - case Workflows.get_workflow_version(socket.assigns.current_scope, version_id) do - {:ok, version} -> - draft = build_version_draft(socket.assigns.workflow, version) - - socket - |> assign(:draft, draft) - |> assign(:revision, %{kind: "version", id: version_id, label: "v#{version.version_tag}"}) - - {:error, reason} -> - Logger.warning("Version lookup failed: #{inspect(reason)}") - - socket - |> put_flash(:error, "Version not found") - |> assign(:draft, socket.assigns.workflow.draft) - |> assign(:revision, %{kind: "current", label: "Current Draft"}) - end - end - - defp load_revision(socket, _params) do - socket - |> assign(:draft, socket.assigns.workflow.draft) - |> assign(:revision, %{kind: "current", label: "Current Draft"}) - end - - defp build_version_draft(workflow, version) do - case workflow.draft do - %{} = draft -> - %{ - draft - | steps: List.wrap(version.steps || []), - connections: List.wrap(version.connections || []), - groups: List.wrap(version.groups || []) - } - - _ -> - %{ - workflow_id: workflow.id, - steps: List.wrap(version.steps || []), - connections: List.wrap(version.connections || []), - groups: List.wrap(version.groups || []), - settings: %{} - } - end - end - - defp apply_current_revision(socket) do - case socket.assigns.revision do - %{kind: "undo", depth: depth} -> - Server.undo(socket.assigns.workflow.id, socket.assigns.current_user_id, depth) - - %{kind: "version", id: version_id} -> - apply_version_restoration(socket, version_id) - - _ -> - {:ok, :no_change} - end - end - - defp apply_version_restoration(socket, version_id) do - with %{} = draft <- socket.assigns.workflow.draft, - {:ok, version} <- - Workflows.get_workflow_version(socket.assigns.current_scope, version_id) do - label = "Restore v#{version.version_tag}" - operations = build_revision_operations(draft, version, label) - - case apply_operations(socket, operations) do - :ok -> {:ok, :applied} - {:error, reason} -> {:error, reason} - end - else - _ -> {:error, :invalid_version} - end - end - - defp fetch_undo_stack(workflow_id, user_id) do - case Server.get_undo_state(workflow_id, user_id) do - {:ok, state} -> Map.get(state, :undoStack, []) - _ -> [] - end - end - - defp fetch_editor_state(workflow_id) do - case Server.get_editor_state(workflow_id) do - {:ok, editor_state} -> editor_state - _ -> nil - end - end - - defp serialize_versions(versions) do - Enum.map(versions, fn version -> - %{ - id: version.id, - version_tag: version.version_tag, - published_at: format_timestamp(version.published_at) - } - end) - end - - defp format_timestamp(%DateTime{} = timestamp), do: DateTime.to_iso8601(timestamp) - defp format_timestamp(_timestamp), do: nil - - defp apply_operations(_socket, []), do: :ok - - defp apply_operations(socket, operations) do - Enum.reduce_while(operations, :ok, fn - {type, payload}, :ok -> - operation = build_operation(socket, type, payload, %{}) - - case Server.apply_operation(socket.assigns.workflow.id, operation) do - {:ok, _result} -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - - {type, payload, opts}, :ok -> - operation = build_operation(socket, type, payload, opts) - - case Server.apply_operation(socket.assigns.workflow.id, operation) do - {:ok, _result} -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - end) - end - - defp build_operation(socket, type, payload, opts) do - %{ - id: UUID.generate(), - type: type, - payload: payload, - user_id: socket.assigns.current_user_id, - client_seq: nil, - undo_group_id: Map.get(opts, :undo_group_id), - undo_label: Map.get(opts, :undo_label) - } - end - - defp build_revision_operations(draft, version, label) do - undo_group_id = UUID.generate() - opts = %{undo_group_id: undo_group_id, undo_label: label} - - remove_group_ops = - draft.groups - |> List.wrap() - |> Enum.map(fn group -> - {:remove_group, %{group_id: fetch_field(group, :id)}, opts} - end) - - remove_step_ops = - draft.steps - |> List.wrap() - |> Enum.map(fn step -> - {:remove_step, %{step_id: fetch_field(step, :id)}, opts} - end) - - target_steps = List.wrap(fetch_field(version, :steps) || []) - target_groups = List.wrap(fetch_field(version, :groups) || []) - target_connections = List.wrap(fetch_field(version, :connections) || []) - - add_step_ops = - Enum.map(target_steps, fn step -> - {:add_step, %{step: normalize_step(step)}, opts} - end) - - add_group_ops = - Enum.map(target_groups, fn group -> - group_map = normalize_group(group) - step_positions = build_group_step_positions(target_steps, group_map) - {:add_group, %{group: group_map, step_positions: step_positions}, opts} - end) - - add_connection_ops = - Enum.map(target_connections, fn connection -> - {:add_connection, %{connection: normalize_connection(connection)}, opts} - end) - - remove_group_ops ++ remove_step_ops ++ add_step_ops ++ add_group_ops ++ add_connection_ops - end - - defp normalize_step(step) do - %{ - id: fetch_field(step, :id), - type_id: fetch_field(step, :type_id), - name: fetch_field(step, :name), - config: fetch_field(step, :config) || %{}, - position: normalize_position(fetch_field(step, :position) || %{}), - notes: fetch_field(step, :notes) - } - end - - defp normalize_connection(connection) do - %{ - id: fetch_field(connection, :id), - source_step_id: fetch_field(connection, :source_step_id), - source_output: fetch_field(connection, :source_output) || "main", - target_step_id: fetch_field(connection, :target_step_id), - target_input: fetch_field(connection, :target_input) || "main" - } - end - - defp normalize_group(group) do - %{ - id: fetch_field(group, :id), - name: fetch_field(group, :name) || "Group", - step_ids: List.wrap(fetch_field(group, :step_ids) || []), - output_step_id: fetch_field(group, :output_step_id), - position: normalize_group_position(fetch_field(group, :position) || %{}), - color: fetch_field(group, :color), - font_size: normalize_group_font_size(fetch_field(group, :font_size)), - collapsed: fetch_field(group, :collapsed) || false - } - end - - defp build_group_step_positions(steps, group) do - step_ids = Map.get(group, :step_ids) || [] - - Enum.reduce(steps, %{}, fn step, acc -> - step_id = fetch_field(step, :id) - - if step_id in step_ids do - position = normalize_position(fetch_field(step, :position) || %{}) - Map.put(acc, step_id, position) - else - acc - end - end) - end - - defp normalize_position(position) when is_map(position) do - %{ - x: fetch_field(position, :x) || 0, - y: fetch_field(position, :y) || 0 - } - end - - defp normalize_position(_position), do: %{x: 0, y: 0} - - defp normalize_group_position(position) when is_map(position) do - %{ - x: fetch_field(position, :x) || 0, - y: fetch_field(position, :y) || 0, - width: fetch_field(position, :width), - height: fetch_field(position, :height) - } - |> Enum.reject(fn {_key, value} -> is_nil(value) end) - |> Map.new() - end - - defp normalize_group_position(_position), do: %{} - - defp normalize_group_font_size(font_size) when is_integer(font_size) do - font_size - |> max(@group_name_font_size_min) - |> min(@group_name_font_size_max) - end - - defp normalize_group_font_size(font_size) when is_binary(font_size) do - case Integer.parse(font_size) do - {parsed, ""} -> normalize_group_font_size(parsed) - _ -> @group_name_font_size_default - end - end - - defp normalize_group_font_size(_font_size), do: @group_name_font_size_default - - defp fetch_field(map, key) when is_map(map) do - Map.get(map, key) || Map.get(map, to_string(key)) - end - - defp fetch_field(_map, _key), do: nil - - defp parse_count(count) when is_integer(count) and count > 0, do: count - - defp parse_count(count) when is_binary(count) do - case Integer.parse(count) do - {value, _} when value > 0 -> value - _ -> 1 - end - end - - defp parse_count(_count), do: 1 -end diff --git a/lib/fizz_web/live/workflow_live/show.ex b/lib/fizz_web/live/workflow_live/show.ex index 03cabc0..86ebd43 100644 --- a/lib/fizz_web/live/workflow_live/show.ex +++ b/lib/fizz_web/live/workflow_live/show.ex @@ -69,15 +69,6 @@ defmodule FizzWeb.WorkflowLive.Show do {status_label(@workflow.status)} -
- <.link - navigate={Paths.workflow_edit_path(@current_scope, @workflow.id)} - class="btn btn-primary gap-2" - > - <.icon name="hero-play" class="size-4" /> - Run Workflow - -

{@workflow.description || "No description provided."}

diff --git a/lib/fizz_web/plugs/webhook_handler.ex b/lib/fizz_web/plugs/webhook_handler.ex deleted file mode 100644 index 8ab804d..0000000 --- a/lib/fizz_web/plugs/webhook_handler.ex +++ /dev/null @@ -1,322 +0,0 @@ -defmodule FizzWeb.Plugs.WebhookHandler do - @moduledoc """ - Plug to handle incoming webhook triggers. - - Routes requests to `/api/hooks/:workflow_id`. - """ - import Plug.Conn - require Logger - - alias Fizz.Repo - alias Fizz.Workflows.Workflow - alias Fizz.Executions - alias Fizz.Workers.ExecutionWorker - alias Fizz.Accounts.Scope - alias Fizz.Collaboration.EditSession.Server, as: EditSessionServer - - def init(opts), do: opts - - def call(conn, _opts) do - path_segments = conn.params["path"] || [] - is_test = conn.request_path =~ ~r{/hook-test/} - path = Enum.join(path_segments, "/") - scope = conn.assigns[:current_scope] - - if is_test and not Scope.authenticated?(scope) do - send_error(conn, 401, "API key required") - else - alias Fizz.Runtime.Triggers.Registry - - # 1. Try lookup by path first (new way) - # Registry lookup is much faster as it bypasses DB search - {workflow, config} = - case Registry.lookup_webhook(path, conn.method) do - {:ok, %{workflow_id: id, config: config}} -> - {Repo.get(Workflow, id), config} - - :error -> - # 2. Try lookup by ID if first segment is a UUID (legacy way) - workflow = - case List.first(path_segments) do - nil -> - nil - - id -> - case Ecto.UUID.cast(id) do - {:ok, uuid} -> Repo.get(Workflow, uuid) - _ -> nil - end - end - - # If it's a test route, we might need a DB lookup if registry doesn't have it - workflow = - if is_nil(workflow) and is_test do - Fizz.Workflows.get_workflow_by_webhook_draft_path(path, conn.method) - else - workflow - end - - {workflow, nil} - end - - case workflow do - nil -> - send_error(conn, 404, "Workflow not found") - - workflow -> - cond do - # Test mode: triggered from /hook-test/ - # We implicitly trust the token/path for test webhooks since it comes from the draft config - is_test -> - workflow = Repo.preload(workflow, :draft) - - if Scope.can_edit_workflow?(scope, workflow) do - case EditSessionServer.test_webhook_enabled?(workflow.id, path, conn.method) do - {:ok, _webhook_test} -> - config = - config || - webhook_config_for( - workflow.draft.steps, - path, - conn.method - ) - - handle_trigger(conn, workflow, scope, config, :preview, true) - - {:error, _reason} -> - send_error(conn, 404, "Test webhook is not enabled") - end - else - send_error(conn, 403, "Access denied") - end - - # Production mode checks - workflow.status != :active -> - send_error(conn, 403, "Workflow is not active") - - is_nil(workflow.published_version_id) -> - send_error(conn, 400, "Workflow is not published") - - true -> - if Scope.can_view_workflow?(scope, workflow) do - # If we didn't get config from registry (e.g. legacy ID lookup), fetch it now - config = - config || - ( - workflow = Repo.preload(workflow, :published_version) - - webhook_config_for( - workflow.published_version.steps, - path, - conn.method - ) - ) - - handle_trigger(conn, workflow, scope, config, :production) - else - send_error(conn, 404, "Workflow not found") - end - end - end - end - end - - defp webhook_config_for(steps, path, method) do - normalized_path = normalize_path(path) - normalized_method = normalize_method(method) - - step = - Enum.find(steps || [], fn step -> - step.type_id == "webhook_trigger" && - normalize_path(Map.get(step.config, "path") || Map.get(step.config, :path) || step.id) == - normalized_path && - method_matches?( - Map.get(step.config, "http_method") || Map.get(step.config, :http_method), - normalized_method - ) - end) - - if step, do: step.config || %{}, else: %{} - end - - defp handle_trigger(conn, workflow, scope, config, execution_type, test_webhook? \\ false) do - response_mode = - Map.get(config, "response_mode") || Map.get(config, :response_mode) || "immediate" - - # 1. Extract payload - payload = %{ - "body" => conn.body_params, - "params" => conn.params, - "headers" => Enum.into(conn.req_headers, %{}), - "method" => conn.method - } - - # 2. Create execution record base attributes - attrs = %{ - workflow_id: workflow.id, - execution_type: execution_type, - trigger: %{ - "type" => "webhook", - "data" => payload - }, - metadata: %{ - "source" => "webhook", - "remote_ip" => to_string(:inet.ntoa(conn.remote_ip)) - } - } - - case response_mode do - "on_respond_node" -> - # Pass handler PID as ephemeral option - case Executions.create_execution(scope, attrs) do - {:ok, execution} -> - _ = maybe_notify_webhook_test_execution(test_webhook?, workflow.id, execution.id) - _ = maybe_disable_test_webhook(test_webhook?, workflow.id) - - # Start execution directly (do not use run_sync as it blocks) - # Pass webhook_handler_pid to Server via Supervisor - case Fizz.Runtime.Execution.Supervisor.start_execution(execution.id, - webhook_handler_pid: self() - ) do - {:ok, pid} -> - monitor_ref = Process.monitor(pid) - wait_for_response(conn, monitor_ref, execution.id) - - {:error, {:already_started, pid}} -> - monitor_ref = Process.monitor(pid) - wait_for_response(conn, monitor_ref, execution.id) - - {:error, reason} -> - handle_creation_error(conn, reason) - end - - {:error, reason} -> - handle_creation_error(conn, reason) - end - - "on_completion" -> - case Executions.create_execution(scope, attrs) do - {:ok, execution} -> - _ = maybe_notify_webhook_test_execution(test_webhook?, workflow.id, execution.id) - _ = maybe_disable_test_webhook(test_webhook?, workflow.id) - ExecutionWorker.run_sync(execution.id) - # Re-fetch to get output - execution = Repo.get!(Fizz.Executions.Execution, execution.id) - - conn - |> put_resp_content_type("application/json") - |> send_resp(200, Jason.encode!(execution.output || %{})) - - {:error, reason} -> - handle_creation_error(conn, reason) - end - - _ -> - # "immediate" or default - case Executions.create_execution(scope, attrs) do - {:ok, execution} -> - _ = maybe_notify_webhook_test_execution(test_webhook?, workflow.id, execution.id) - _ = maybe_disable_test_webhook(test_webhook?, workflow.id) - ExecutionWorker.enqueue(execution.id) - - conn - |> put_resp_content_type("application/json") - |> send_resp( - 202, - Jason.encode!(%{ - status: "accepted", - execution_id: execution.id - }) - ) - - {:error, reason} -> - handle_creation_error(conn, reason) - end - end - end - - defp maybe_disable_test_webhook(true, workflow_id) do - _ = EditSessionServer.disable_test_webhook(workflow_id) - :ok - end - - defp maybe_disable_test_webhook(false, _workflow_id), do: :ok - - defp maybe_notify_webhook_test_execution(true, workflow_id, execution_id) do - _ = EditSessionServer.notify_webhook_test_execution(workflow_id, execution_id) - :ok - end - - defp maybe_notify_webhook_test_execution(false, _workflow_id, _execution_id), do: :ok - - defp normalize_path(nil), do: nil - - defp normalize_path(path) when is_binary(path) do - path - |> String.trim() - |> String.trim_leading("/") - |> String.trim_trailing("/") - |> case do - "" -> nil - trimmed -> trimmed - end - end - - defp normalize_method(nil), do: "POST" - - defp normalize_method(method) when is_binary(method) do - method - |> String.trim() - |> case do - "" -> "POST" - trimmed -> String.upcase(trimmed) - end - end - - defp method_matches?(configured_method, incoming_method) do - normalized_config = normalize_method(configured_method) - normalized_incoming = normalize_method(incoming_method) - - normalized_config == "ANY" or normalized_config == normalized_incoming - end - - defp handle_creation_error(conn, :access_denied) do - send_error(conn, 403, "Access denied") - end - - defp handle_creation_error(conn, :workflow_not_published) do - send_error(conn, 400, "Workflow is not published") - end - - defp handle_creation_error(conn, reason) do - Logger.error("Failed to create execution for webhook: #{inspect(reason)}") - send_error(conn, 500, "Internal error") - end - - defp wait_for_response(conn, monitor_ref, _execution_id) do - receive do - {:webhook_response, data} -> - # We got the response we wanted! - Process.demonitor(monitor_ref, [:flush]) - - conn - |> put_resp_content_type(Map.get(data, :content_type, "application/json")) - |> send_resp(Map.get(data, :status, 200), Jason.encode!(Map.get(data, :body, %{}))) - - {:DOWN, ^monitor_ref, :process, _pid, _reason} -> - # Process died before sending response - send_error(conn, 502, "Workflow completed without sending a response") - after - 30_000 -> - Process.demonitor(monitor_ref, [:flush]) - send_error(conn, 504, "Workflow timed out waiting for response") - end - end - - defp send_error(conn, status, message) do - conn - |> put_resp_content_type("application/json") - |> send_resp(status, Jason.encode!(%{errors: %{detail: message}})) - |> halt() - end -end diff --git a/lib/fizz_web/router.ex b/lib/fizz_web/router.ex index 02ab0c3..aeb2a7b 100644 --- a/lib/fizz_web/router.ex +++ b/lib/fizz_web/router.ex @@ -56,8 +56,6 @@ defmodule FizzWeb.Router do pipe_through :api get "/workflows/:id/contract", WorkflowContractController, :show - match :*, "/hooks/*path", Plugs.WebhookHandler, :handle - match :*, "/hook-test/*path", Plugs.WebhookHandler, :handle end scope "/", FizzWeb do @@ -86,9 +84,6 @@ defmodule FizzWeb.Router do live "/workspaces/:workspace_id/workflows/:workflow_id/execution/:execution_id", ExecutionLive.Show, :show - - live "/workspaces/:workspace_id/workflows/:id/edit", WorkflowLive.Edit, :edit - live "/workspaces/:workspace_id/workflows/:id/revisions", WorkflowLive.Revision, :index end end end diff --git a/test/fizz/collaboration/edit_session/inversion_commit_drag_layout_test.exs b/test/fizz/collaboration/edit_session/inversion_commit_drag_layout_test.exs deleted file mode 100644 index ee5309a..0000000 --- a/test/fizz/collaboration/edit_session/inversion_commit_drag_layout_test.exs +++ /dev/null @@ -1,153 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.InversionCommitDragLayoutTest do - use ExUnit.Case, async: true - - alias Fizz.Collaboration.EditorState - alias Fizz.Collaboration.EditSession.{Inversion, Operations} - alias Fizz.Workflows.Embeds.{NodeGroup, Step} - alias Fizz.Workflows.WorkflowDraft - - describe "compute_inverse/3 for :commit_drag_layout" do - test "captures previous bounds, step positions, and memberships" do - draft = base_draft() - - operation = %{ - type: :commit_drag_layout, - payload: %{ - txn_id: "txn-inverse-1", - base_seq: 21, - groups: [ - %{group_id: "group_a", position: %{x: 130, y: 140, width: 420, height: 320}}, - %{group_id: "group_b", position: %{x: 560, y: 190, width: 260, height: 210}} - ], - step_positions: %{ - "step_a" => %{x: 40, y: 30}, - "step_c" => %{x: 55, y: 65} - }, - group_id_by_step_id: %{ - "step_b" => nil, - "step_c" => "group_b" - } - } - } - - assert {:ok, [inverse]} = Inversion.compute_inverse(draft, %EditorState{}, operation) - assert inverse.type == :commit_drag_layout - assert inverse.payload.txn_id == "txn-inverse-1" - assert inverse.payload.base_seq == 21 - - assert inverse.payload.groups == [ - %{group_id: "group_a", position: %{x: 100, y: 100, width: 320, height: 240}}, - %{group_id: "group_b", position: %{x: 540, y: 160, width: 280, height: 220}} - ] - - assert inverse.payload.step_positions == %{ - "step_a" => %{x: 60, y: 60}, - "step_c" => %{x: 860, y: 260} - } - - assert inverse.payload.group_id_by_step_id == %{ - "step_b" => "group_a", - "step_c" => nil - } - end - - test "inverse operation restores previous draft state" do - draft = base_draft() - operation = move_operation() - - assert {:ok, [inverse]} = Inversion.compute_inverse(draft, %EditorState{}, operation) - assert {:ok, moved_draft} = Operations.apply(draft, operation) - assert {:ok, restored_draft} = Operations.apply(moved_draft, inverse) - - assert snapshot(restored_draft) == snapshot(draft) - end - end - - defp move_operation do - %{ - type: :commit_drag_layout, - payload: %{ - txn_id: "txn-inverse-2", - base_seq: 22, - groups: [ - %{group_id: "group_a", position: %{x: 130, y: 140, width: 420, height: 320}}, - %{group_id: "group_b", position: %{x: 560, y: 190, width: 260, height: 210}} - ], - step_positions: %{ - "step_a" => %{x: 40, y: 30}, - "step_c" => %{x: 55, y: 65} - }, - group_id_by_step_id: %{ - "step_b" => nil, - "step_c" => "group_b" - } - } - } - end - - defp snapshot(%WorkflowDraft{} = draft) do - %{ - groups: - draft.groups - |> List.wrap() - |> Enum.map(fn group -> - %{ - id: group.id, - step_ids: group.step_ids, - output_step_id: group.output_step_id, - position: group.position - } - end), - steps: - draft.steps - |> List.wrap() - |> Enum.map(fn step -> - %{id: step.id, position: step.position} - end) - } - end - - defp base_draft do - %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - step("step_a", %{x: 60, y: 60}), - step("step_b", %{x: 140, y: 110}), - step("step_c", %{x: 860, y: 260}), - step("step_d", %{x: 50, y: 70}) - ], - connections: [], - groups: [ - group("group_a", ["step_a", "step_b"], "step_a", %{ - x: 100, - y: 100, - width: 320, - height: 240 - }), - group("group_b", ["step_d"], "step_d", %{x: 540, y: 160, width: 280, height: 220}) - ] - } - end - - defp step(id, position) do - %Step{ - id: id, - type_id: "math", - name: id, - config: %{}, - position: position - } - end - - defp group(id, step_ids, output_step_id, position) do - %NodeGroup{ - id: id, - name: id, - step_ids: step_ids, - output_step_id: output_step_id, - position: position, - color: nil, - collapsed: false - } - end -end diff --git a/test/fizz/collaboration/edit_session/inversion_update_step_positions_test.exs b/test/fizz/collaboration/edit_session/inversion_update_step_positions_test.exs deleted file mode 100644 index 171c5ee..0000000 --- a/test/fizz/collaboration/edit_session/inversion_update_step_positions_test.exs +++ /dev/null @@ -1,70 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.InversionUpdateStepPositionsTest do - use ExUnit.Case, async: true - - alias Fizz.Collaboration.EditorState - alias Fizz.Collaboration.EditSession.Inversion - alias Fizz.Workflows.WorkflowDraft - alias Fizz.Workflows.Embeds.Step - - describe "compute_inverse/3 for :update_step_positions" do - test "captures previous positions for all updated steps" do - draft = base_draft() - - operation = %{ - type: :update_step_positions, - payload: %{ - step_positions: %{ - "step_a" => %{x: 100, y: 200}, - "step_b" => %{x: 300, y: 400} - } - } - } - - assert {:ok, [inverse]} = Inversion.compute_inverse(draft, %EditorState{}, operation) - assert inverse.type == :update_step_positions - - assert inverse.payload == %{ - step_positions: %{ - "step_a" => %{x: 10, y: 20}, - "step_b" => %{x: 30, y: 40} - } - } - end - - test "fails when payload includes unknown step ids" do - draft = base_draft() - - operation = %{ - type: :update_step_positions, - payload: %{step_positions: %{"missing_step" => %{x: 100, y: 200}}} - } - - assert {:error, {:steps_not_found, missing_ids}} = - Inversion.compute_inverse(draft, %EditorState{}, operation) - - assert "missing_step" in missing_ids - end - end - - defp base_draft do - %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - step("step_a", %{x: 10, y: 20}), - step("step_b", %{x: 30, y: 40}) - ], - connections: [], - groups: [] - } - end - - defp step(id, position) do - %Step{ - id: id, - type_id: "math", - name: id, - config: %{}, - position: position - } - end -end diff --git a/test/fizz/collaboration/edit_session/operations_add_step_group_resize_test.exs b/test/fizz/collaboration/edit_session/operations_add_step_group_resize_test.exs deleted file mode 100644 index 9e8400d..0000000 --- a/test/fizz/collaboration/edit_session/operations_add_step_group_resize_test.exs +++ /dev/null @@ -1,107 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.OperationsAddStepGroupResizeTest do - use ExUnit.Case, async: true - - alias Fizz.Collaboration.EditSession.Operations - alias Fizz.Workflows.Embeds.{NodeGroup, Step} - alias Fizz.Workflows.WorkflowDraft - - test "add_step into group expands group bounds when step overflows content area" do - draft = base_draft() - - operation = %{ - type: :add_step, - payload: %{ - step: %{ - id: "step_new", - type_id: "math", - name: "step_new", - config: %{}, - position: %{x: 320, y: 200} - }, - group_id: "group_a" - } - } - - assert :ok = Operations.validate(draft, operation) - assert {:ok, updated_draft} = Operations.apply(draft, operation) - - assert group_step_ids_for(updated_draft, "group_a") == ["step_a", "step_new"] - - assert group_position_for(updated_draft, "group_a") == %{ - x: 100, - y: 100, - width: 494, - height: 302 - } - end - - test "add_step expansion uses provided step_size when present" do - draft = base_draft() - - operation = %{ - type: :add_step, - payload: %{ - step: %{ - id: "step_large", - type_id: "math", - name: "step_large", - config: %{}, - position: %{x: 320, y: 200} - }, - group_id: "group_a", - step_size: %{width: 300, height: 100} - } - } - - assert :ok = Operations.validate(draft, operation) - assert {:ok, updated_draft} = Operations.apply(draft, operation) - - assert group_position_for(updated_draft, "group_a") == %{ - x: 100, - y: 100, - width: 644, - height: 352 - } - end - - defp base_draft do - %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - %Step{ - id: "step_a", - type_id: "math", - name: "step_a", - config: %{}, - position: %{x: 40, y: 40} - } - ], - connections: [], - groups: [ - %NodeGroup{ - id: "group_a", - name: "Group A", - step_ids: ["step_a"], - output_step_id: "step_a", - position: %{x: 100, y: 100, width: 360, height: 240}, - color: nil, - collapsed: false - } - ] - } - end - - defp group_position_for(%WorkflowDraft{} = draft, group_id) do - draft.groups - |> List.wrap() - |> Enum.find(fn group -> group.id == group_id end) - |> Map.get(:position) - end - - defp group_step_ids_for(%WorkflowDraft{} = draft, group_id) do - draft.groups - |> List.wrap() - |> Enum.find(fn group -> group.id == group_id end) - |> Map.get(:step_ids) - end -end diff --git a/test/fizz/collaboration/edit_session/operations_commit_drag_layout_test.exs b/test/fizz/collaboration/edit_session/operations_commit_drag_layout_test.exs deleted file mode 100644 index 3e2e765..0000000 --- a/test/fizz/collaboration/edit_session/operations_commit_drag_layout_test.exs +++ /dev/null @@ -1,225 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.OperationsCommitDragLayoutTest do - use ExUnit.Case, async: true - - alias Fizz.Collaboration.EditSession.Operations - alias Fizz.Workflows.Embeds.{NodeGroup, Step} - alias Fizz.Workflows.WorkflowDraft - - describe "apply/2 for :commit_drag_layout" do - test "persists expanded group bounds from drop commit" do - draft = base_draft() - - operation = %{ - type: :commit_drag_layout, - payload: %{ - txn_id: "txn-expand-1", - base_seq: 3, - groups: [ - %{ - group_id: "group_a", - position: %{x: 120, y: 140, width: 460, height: 320} - } - ], - step_positions: %{}, - group_id_by_step_id: %{} - } - } - - assert :ok = Operations.validate(draft, operation) - assert {:ok, updated_draft} = Operations.apply(draft, operation) - - assert group_position_for(updated_draft, "group_a") == %{ - x: 120, - y: 140, - width: 460, - height: 320 - } - end - - test "keeps child absolute positions stable when group origin shifts" do - draft = base_draft() - - before_absolute = - %{ - "step_a" => absolute_position_for(draft, "step_a"), - "step_b" => absolute_position_for(draft, "step_b"), - "step_c" => absolute_position_for(draft, "step_c") - } - - operation = %{ - type: :commit_drag_layout, - payload: %{ - txn_id: "txn-origin-shift-1", - base_seq: 10, - groups: [ - %{ - group_id: "group_a", - position: %{x: 80, y: 90, width: 360, height: 260} - } - ], - step_positions: %{ - "step_a" => %{x: 80, y: 70} - }, - group_id_by_step_id: %{} - } - } - - assert :ok = Operations.validate(draft, operation) - assert {:ok, updated_draft} = Operations.apply(draft, operation) - - assert absolute_position_for(updated_draft, "step_a") == before_absolute["step_a"] - assert absolute_position_for(updated_draft, "step_b") == before_absolute["step_b"] - assert absolute_position_for(updated_draft, "step_c") == before_absolute["step_c"] - end - - test "applies memberships, bounds, and positions coherently in one operation" do - draft = base_draft() - - operation = %{ - type: :commit_drag_layout, - payload: %{ - txn_id: "txn-coherent-1", - base_seq: 14, - groups: [ - %{ - group_id: "group_a", - position: %{x: 95, y: 105, width: 380, height: 280} - }, - %{ - group_id: "group_b", - position: %{x: 520, y: 120, width: 300, height: 220} - } - ], - step_positions: %{ - "step_b" => %{x: 10, y: 15}, - "step_c" => %{x: 65, y: 75} - }, - group_id_by_step_id: %{ - "step_b" => nil, - "step_c" => "group_a" - } - } - } - - assert :ok = Operations.validate(draft, operation) - assert {:ok, updated_draft} = Operations.apply(draft, operation) - - assert group_position_for(updated_draft, "group_a") == %{ - x: 95, - y: 105, - width: 380, - height: 280 - } - - assert group_position_for(updated_draft, "group_b") == %{ - x: 520, - y: 120, - width: 300, - height: 220 - } - - assert position_for(updated_draft, "step_b") == %{x: 10, y: 15} - assert position_for(updated_draft, "step_c") == %{x: 65, y: 75} - - assert group_step_ids_for(updated_draft, "group_a") |> Enum.sort() == ["step_a", "step_c"] - assert group_step_ids_for(updated_draft, "group_b") == ["step_d"] - assert group_output_step_for(updated_draft, "group_b") == "step_d" - end - end - - defp base_draft do - %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - step("step_a", %{x: 60, y: 60}), - step("step_b", %{x: 140, y: 110}), - step("step_c", %{x: 860, y: 260}), - step("step_d", %{x: 50, y: 70}) - ], - connections: [], - groups: [ - group("group_a", ["step_a"], "step_a", %{x: 100, y: 100, width: 320, height: 240}), - group("group_b", ["step_b", "step_d"], "step_b", %{ - x: 500, - y: 100, - width: 280, - height: 220 - }) - ] - } - end - - defp step(id, position) do - %Step{ - id: id, - type_id: "math", - name: id, - config: %{}, - position: position - } - end - - defp group(id, step_ids, output_step_id, position) do - %NodeGroup{ - id: id, - name: id, - step_ids: step_ids, - output_step_id: output_step_id, - position: position, - color: nil, - collapsed: false - } - end - - defp position_for(%WorkflowDraft{} = draft, step_id) do - draft.steps - |> Enum.find(fn step -> step.id == step_id end) - |> Map.get(:position) - end - - defp group_position_for(%WorkflowDraft{} = draft, group_id) do - draft.groups - |> List.wrap() - |> Enum.find(fn group -> group.id == group_id end) - |> Map.get(:position) - end - - defp group_step_ids_for(%WorkflowDraft{} = draft, group_id) do - draft.groups - |> List.wrap() - |> Enum.find(fn group -> group.id == group_id end) - |> Map.get(:step_ids) - end - - defp group_output_step_for(%WorkflowDraft{} = draft, group_id) do - draft.groups - |> List.wrap() - |> Enum.find(fn group -> group.id == group_id end) - |> Map.get(:output_step_id) - end - - defp absolute_position_for(%WorkflowDraft{} = draft, step_id) do - step_position = position_for(draft, step_id) - - case find_group_for_step(draft, step_id) do - nil -> - step_position - - group -> - group_position = Map.get(group, :position) || %{} - - %{ - x: (Map.get(group_position, :x) || 0) + (Map.get(step_position, :x) || 0), - y: (Map.get(group_position, :y) || 0) + (Map.get(step_position, :y) || 0) - } - end - end - - defp find_group_for_step(%WorkflowDraft{} = draft, step_id) do - draft.groups - |> List.wrap() - |> Enum.find(fn group -> - step_id in (Map.get(group, :step_ids) || []) - end) - end -end diff --git a/test/fizz/collaboration/edit_session/operations_subnodes_test.exs b/test/fizz/collaboration/edit_session/operations_subnodes_test.exs deleted file mode 100644 index 726d94a..0000000 --- a/test/fizz/collaboration/edit_session/operations_subnodes_test.exs +++ /dev/null @@ -1,104 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.OperationsSubnodesTest do - use ExUnit.Case, async: true - - alias Fizz.Collaboration.EditSession.Operations - alias Fizz.Workflows.WorkflowDraft - alias Fizz.Workflows.Embeds.{Connection, Step} - - describe "validate/2 add_connection with slot constraints" do - test "rejects unknown target_input slot" do - draft = base_draft() - - operation = %{ - type: :add_connection, - payload: %{ - connection: %{ - id: "c_unknown", - source_step_id: "model", - target_step_id: "agent", - source_output: "main", - target_input: "unknown_slot" - } - } - } - - assert {:error, {:invalid_target_input, "unknown_slot"}} = - Operations.validate(draft, operation) - end - - test "rejects source step type not accepted by slot" do - draft = base_draft() - - operation = %{ - type: :add_connection, - payload: %{ - connection: %{ - id: "c_bad_type", - source_step_id: "math", - target_step_id: "agent", - source_output: "main", - target_input: "model" - } - } - } - - assert {:error, {:slot_disallows_source_type, "model", "math"}} = - Operations.validate(draft, operation) - end - - test "rejects additional connection to one-cardinality slot" do - draft = - base_draft() - |> Map.put(:connections, [ - %Connection{ - id: "existing_model", - source_step_id: "model", - source_output: "main", - target_step_id: "agent", - target_input: "model" - } - ]) - - operation = %{ - type: :add_connection, - payload: %{ - connection: %{ - id: "c_extra_model", - source_step_id: "model_alt", - target_step_id: "agent", - source_output: "main", - target_input: "model" - } - } - } - - assert {:error, {:slot_cardinality_exceeded, "model"}} = - Operations.validate(draft, operation) - end - end - - defp base_draft do - %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - step("agent", "ai_agent"), - step("model", "openai_model"), - step("model_alt", "anthropic_model"), - step("prompt", "ai_prompt_template"), - step("math", "math") - ], - connections: [], - groups: [] - } - end - - defp step(id, type_id) do - %Step{ - id: id, - type_id: type_id, - name: id, - config: %{}, - position: %{} - } - end -end diff --git a/test/fizz/collaboration/edit_session/operations_update_step_positions_test.exs b/test/fizz/collaboration/edit_session/operations_update_step_positions_test.exs deleted file mode 100644 index 9f3d5e0..0000000 --- a/test/fizz/collaboration/edit_session/operations_update_step_positions_test.exs +++ /dev/null @@ -1,87 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.OperationsUpdateStepPositionsTest do - use ExUnit.Case, async: true - - alias Fizz.Collaboration.EditSession.Operations - alias Fizz.Workflows.WorkflowDraft - alias Fizz.Workflows.Embeds.Step - - describe "validate/2 for :update_step_positions" do - test "accepts known step ids" do - draft = base_draft() - - operation = %{ - type: :update_step_positions, - payload: %{ - step_positions: %{ - "step_a" => %{x: 100, y: 200}, - "step_b" => %{x: 300, y: 400} - } - } - } - - assert :ok = Operations.validate(draft, operation) - end - - test "rejects missing step ids" do - draft = base_draft() - - operation = %{ - type: :update_step_positions, - payload: %{step_positions: %{"missing_step" => %{x: 100, y: 200}}} - } - - assert {:error, {:steps_not_found, missing_ids}} = Operations.validate(draft, operation) - assert "missing_step" in missing_ids - end - end - - describe "apply/2 for :update_step_positions" do - test "updates all provided step positions in one operation" do - draft = base_draft() - - operation = %{ - type: :update_step_positions, - payload: %{ - step_positions: %{ - "step_a" => %{x: 110, y: 210}, - "step_b" => %{x: 310, y: 410} - } - } - } - - assert {:ok, updated_draft} = Operations.apply(draft, operation) - assert position_for(updated_draft, "step_a") == %{x: 110, y: 210} - assert position_for(updated_draft, "step_b") == %{x: 310, y: 410} - assert position_for(updated_draft, "step_c") == %{x: 50, y: 60} - end - end - - defp base_draft do - %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - step("step_a", %{x: 10, y: 20}), - step("step_b", %{x: 30, y: 40}), - step("step_c", %{x: 50, y: 60}) - ], - connections: [], - groups: [] - } - end - - defp step(id, position) do - %Step{ - id: id, - type_id: "math", - name: id, - config: %{}, - position: position - } - end - - defp position_for(%WorkflowDraft{} = draft, step_id) do - draft.steps - |> Enum.find(fn step -> step.id == step_id end) - |> Map.get(:position) - end -end diff --git a/test/fizz/collaboration/edit_session/server_commit_drag_layout_test.exs b/test/fizz/collaboration/edit_session/server_commit_drag_layout_test.exs deleted file mode 100644 index cbbf138..0000000 --- a/test/fizz/collaboration/edit_session/server_commit_drag_layout_test.exs +++ /dev/null @@ -1,167 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.ServerCommitDragLayoutTest do - use Fizz.DataCase, async: false - - import Fizz.AccountsFixtures - - alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership} - alias Fizz.Collaboration.EditSession.Server - alias Fizz.Repo - alias Fizz.Workflows - - test "overlapping drag layout commits converge by server seq order" do - user_one = user_fixture() - user_two = user_fixture() - workspace = workspace_fixture!(user_one, [user_two]) - scope = scoped_workspace_access(user_one, workspace) - workflow = workflow_fixture!(scope) - _server_pid = start_supervised!({Server, workflow_id: workflow.id, scope: scope}) - - op_one = - commit_drag_layout_operation(user_one.id, "txn-server-1", %{ - group: %{x: 120, y: 140, width: 420, height: 300}, - step: %{x: 20, y: 25} - }) - - op_two = - commit_drag_layout_operation(user_two.id, "txn-server-2", %{ - group: %{x: 180, y: 220, width: 460, height: 340}, - step: %{x: 65, y: 70} - }) - - task_one = Task.async(fn -> Server.apply_operation(workflow.id, op_one) end) - task_two = Task.async(fn -> Server.apply_operation(workflow.id, op_two) end) - - assert {:ok, %{seq: seq_one, status: :applied}} = Task.await(task_one) - assert {:ok, %{seq: seq_two, status: :applied}} = Task.await(task_two) - assert seq_one != seq_two - - assert {:ok, %{type: :full_sync, draft: draft, seq: final_seq}} = - Server.get_sync_state(workflow.id) - - assert final_seq == max(seq_one, seq_two) - - expected_payload = - if seq_one > seq_two do - op_one.payload - else - op_two.payload - end - - expected_group_position = - expected_payload - |> Map.get(:groups) - |> List.first() - |> Map.get(:position) - - expected_step_position = - expected_payload - |> Map.get(:step_positions) - |> Map.get("step_a") - - assert group_position_for(draft, "group_a") == expected_group_position - assert step_position_for(draft, "step_a") == expected_step_position - end - - defp commit_drag_layout_operation(user_id, txn_id, %{group: group_position, step: step_position}) do - %{ - id: Ecto.UUID.generate(), - type: :commit_drag_layout, - payload: %{ - txn_id: txn_id, - base_seq: 0, - groups: [%{group_id: "group_a", position: group_position}], - step_positions: %{"step_a" => step_position}, - group_id_by_step_id: %{} - }, - user_id: user_id, - client_seq: nil - } - end - - defp group_position_for(draft, group_id) do - draft.groups - |> List.wrap() - |> Enum.find(fn group -> Map.get(group, :id) == group_id end) - |> Map.get(:position) - end - - defp step_position_for(draft, step_id) do - draft.steps - |> List.wrap() - |> Enum.find(fn step -> Map.get(step, :id) == step_id end) - |> Map.get(:position) - end - - defp workflow_fixture!(scope) do - {:ok, workflow} = - Workflows.create_workflow(scope, %{ - name: "Server Commit Layout #{System.unique_integer([:positive])}", - description: "server test" - }) - - {:ok, _draft} = - Workflows.update_workflow_draft(scope, workflow, %{ - steps: [ - %{ - id: "step_a", - type_id: "math", - name: "Step A", - config: %{}, - position: %{x: 60, y: 60} - }, - %{ - id: "step_b", - type_id: "math", - name: "Step B", - config: %{}, - position: %{x: 150, y: 130} - } - ], - connections: [], - groups: [ - %{ - id: "group_a", - name: "Group A", - step_ids: ["step_a", "step_b"], - output_step_id: "step_a", - position: %{x: 100, y: 100, width: 320, height: 240}, - color: nil, - collapsed: false - } - ] - }) - - workflow - end - - defp workspace_fixture!(owner_user, additional_users) do - unique = System.unique_integer([:positive]) - - workspace = - %Workspace{} - |> Workspace.changeset(%{ - name: "Server Commit Workspace #{unique}", - slug: "server-commit-workspace-#{unique}", - workos_organization_id: "org_#{unique}" - }) - |> Repo.insert!() - - member_ids = [owner_user.id | Enum.map(additional_users, & &1.id)] - - Enum.each(member_ids, fn user_id -> - %WorkspaceMembership{workspace_id: workspace.id, user_id: user_id} - |> WorkspaceMembership.changeset(%{role: :admin}) - |> Repo.insert!() - end) - - workspace - end - - defp scoped_workspace_access(user, workspace) do - Scope.for_user(user) - |> Scope.with_organization_id(workspace.workos_organization_id) - |> Scope.with_organization_role(:owner) - |> Scope.with_workspace(workspace) - |> Scope.with_workspace_role(:admin) - end -end diff --git a/test/fizz/collaboration/edit_session/server_persistence_test.exs b/test/fizz/collaboration/edit_session/server_persistence_test.exs deleted file mode 100644 index fc4f548..0000000 --- a/test/fizz/collaboration/edit_session/server_persistence_test.exs +++ /dev/null @@ -1,86 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.ServerPersistenceTest do - use Fizz.DataCase, async: false - - import Ecto.Query - import Fizz.AccountsFixtures - - alias Fizz.Accounts.Scope - alias Fizz.Collaboration.EditSession.Server - alias Fizz.Repo - alias Fizz.Workflows - alias Fizz.Workflows.WorkflowDraft - - test "persist_sync refreshes the session draft timestamp from the database" do - user = user_fixture() - org_scope = organization_scope_fixture(user: user) - workspace = workspace_fixture(org_scope) - - scope = - org_scope - |> Scope.with_workspace(workspace) - |> Scope.with_workspace_role(:admin) - - workflow = workflow_fixture!(scope) - stale_updated_at = DateTime.add(DateTime.utc_now(), -3600, :second) - - Repo.update_all( - from(d in WorkflowDraft, where: d.workflow_id == ^workflow.id), - set: [updated_at: stale_updated_at] - ) - - _server_pid = start_supervised!({Server, workflow_id: workflow.id, scope: scope}) - - assert {:ok, %{type: :full_sync, draft: initial_draft}} = Server.get_sync_state(workflow.id) - assert DateTime.compare(initial_draft.updated_at, stale_updated_at) == :eq - - assert {:ok, %{status: :applied}} = - Server.apply_operation(workflow.id, update_step_position_operation(user.id)) - - assert :ok = Server.persist_sync(workflow.id) - - persisted_draft = Repo.get_by!(WorkflowDraft, workflow_id: workflow.id) - - assert {:ok, %{type: :full_sync, draft: synced_draft}} = Server.get_sync_state(workflow.id) - assert DateTime.compare(persisted_draft.updated_at, stale_updated_at) == :gt - assert DateTime.compare(synced_draft.updated_at, stale_updated_at) == :gt - assert DateTime.compare(synced_draft.updated_at, persisted_draft.updated_at) == :eq - end - - defp update_step_position_operation(user_id) do - %{ - id: Ecto.UUID.generate(), - type: :update_step_position, - payload: %{ - step_id: "step_a", - position: %{x: 180, y: 220} - }, - user_id: user_id, - client_seq: nil - } - end - - defp workflow_fixture!(scope) do - {:ok, workflow} = - Workflows.create_workflow(scope, %{ - name: "Persisted Draft #{System.unique_integer([:positive])}", - description: "persistence test" - }) - - {:ok, _draft} = - Workflows.update_workflow_draft(scope, workflow, %{ - steps: [ - %{ - id: "step_a", - type_id: "math", - name: "Step A", - config: %{}, - position: %{x: 60, y: 60} - } - ], - connections: [], - groups: [] - }) - - workflow - end -end diff --git a/test/fizz/collaboration/edit_session/supervisor_test.exs b/test/fizz/collaboration/edit_session/supervisor_test.exs deleted file mode 100644 index a6e84b8..0000000 --- a/test/fizz/collaboration/edit_session/supervisor_test.exs +++ /dev/null @@ -1,58 +0,0 @@ -defmodule Fizz.Collaboration.EditSession.SupervisorTest do - use Fizz.DataCase, async: false - - import Fizz.AccountsFixtures - - alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership} - alias Fizz.Collaboration.EditSession.Supervisor - alias Fizz.Repo - alias Fizz.Workflows - - test "ensure_session creates a draft when one does not exist" do - user = user_fixture() - workspace = workspace_fixture!(user) - scope = scoped_workspace_access(user, workspace) - - {:ok, workflow} = - Workflows.create_workflow(scope, %{ - name: "Draftless #{System.unique_integer([:positive])}", - description: "Workflow without a draft row" - }) - - assert {:error, :not_found} = Workflows.get_draft(scope, workflow.id) - - assert {:ok, pid} = Supervisor.ensure_session(scope, workflow.id) - assert {:ok, _draft} = Workflows.get_draft(scope, workflow.id) - - ref = Process.monitor(pid) - assert :ok = Supervisor.stop_session(workflow.id) - assert_receive {:DOWN, ^ref, :process, ^pid, _reason} - end - - defp workspace_fixture!(user) do - unique = System.unique_integer([:positive]) - - workspace = - %Workspace{} - |> Workspace.changeset(%{ - name: "Edit Session Workspace #{unique}", - slug: "edit-session-workspace-#{unique}", - workos_organization_id: "org_#{unique}" - }) - |> Repo.insert!() - - %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id} - |> WorkspaceMembership.changeset(%{role: :admin}) - |> Repo.insert!() - - workspace - end - - defp scoped_workspace_access(user, workspace) do - Scope.for_user(user) - |> Scope.with_organization_id(workspace.workos_organization_id) - |> Scope.with_organization_role(:owner) - |> Scope.with_workspace(workspace) - |> Scope.with_workspace_role(:admin) - end -end diff --git a/test/fizz/runtime/runic_adapter_test.exs b/test/fizz/runtime/runic_adapter_test.exs deleted file mode 100644 index 0bfeff8..0000000 --- a/test/fizz/runtime/runic_adapter_test.exs +++ /dev/null @@ -1,233 +0,0 @@ -defmodule Fizz.Runtime.RunicAdapterTest do - use ExUnit.Case, async: true - - alias Fizz.Accounts.Scope - alias Runic.Component - alias Runic.Workflow - alias Fizz.Runtime.Hooks.Observability - alias Fizz.Runtime.RunicAdapter - alias Fizz.Workflows.Embeds.Connection - alias Fizz.Workflows.Embeds.Step - - describe "splitter fan-out execution" do - test "runs downstream steps per item and aggregates all items" do - source = - workflow_source( - [ - step("splitter", "splitter", %{"field" => "items"}), - step("debug_item", "debug", %{"label" => "Item", "level" => "info"}), - step("aggregate", "aggregator", %{"operation" => "collect"}) - ], - [ - connection("c1", "splitter", "debug_item"), - connection("c2", "debug_item", "aggregate") - ] - ) - - outputs = run_workflow(source, %{"items" => [1, 2, 3]}) - - assert Enum.sort(outputs["aggregate"]) == [1, 2, 3] - assert outputs["debug_item"] in [1, 2, 3] - end - end - - describe "aggregator mode selection" do - test "uses reduce only for aggregators in the active fan-out path" do - source = - workflow_source( - [ - step("splitter", "splitter", %{"field" => "items"}), - step("debug_item", "debug", %{"label" => "Item", "level" => "info"}), - step("aggregate_in_fanout", "aggregator", %{"operation" => "collect"}), - step("debug_after_fanout", "debug", %{"label" => "After", "level" => "info"}), - step("aggregate_after_fanout", "aggregator", %{"operation" => "count"}) - ], - [ - connection("c1", "splitter", "debug_item"), - connection("c2", "debug_item", "aggregate_in_fanout"), - connection("c3", "aggregate_in_fanout", "debug_after_fanout"), - connection("c4", "debug_after_fanout", "aggregate_after_fanout") - ] - ) - - workflow = RunicAdapter.to_runic_workflow(source, execution_id: "exec_test") - - assert match?( - %Runic.Workflow.Reduce{}, - Workflow.get_component!(workflow, "aggregate_in_fanout") - ) - - refute match?( - %Runic.Workflow.Reduce{}, - Workflow.get_component!(workflow, "aggregate_after_fanout") - ) - - outputs = run_workflow(source, %{"items" => [1, 2, 3]}) - assert outputs["aggregate_after_fanout"] == 3 - end - end - - describe "fan-out aware join mapping" do - test "maps each parent to its nearest splitter in join fan_out_sources" do - source = - workflow_source( - [ - step("splitter_a", "splitter", %{"field" => "items_a"}), - step("splitter_b", "splitter", %{"field" => "items_b"}), - step("debug_a", "debug", %{"label" => "A", "level" => "info"}), - step("debug_b", "debug", %{"label" => "B", "level" => "info"}), - step("joined_debug", "debug", %{"label" => "Joined", "level" => "info"}) - ], - [ - connection("c1", "splitter_a", "debug_a"), - connection("c2", "splitter_b", "debug_b"), - connection("c3", "debug_a", "joined_debug"), - connection("c4", "debug_b", "joined_debug") - ] - ) - - workflow = RunicAdapter.to_runic_workflow(source, execution_id: "exec_test") - - [join] = - workflow.graph - |> Graph.vertices() - |> Enum.filter(&match?(%Runic.Workflow.Join{}, &1)) - - splitter_a = Workflow.get_component!(workflow, "splitter_a") - splitter_b = Workflow.get_component!(workflow, "splitter_b") - debug_a = Workflow.get_component!(workflow, "debug_a") - debug_b = Workflow.get_component!(workflow, "debug_b") - - assert join.fan_out_sources[Component.hash(debug_a)] == Component.hash(splitter_a) - assert join.fan_out_sources[Component.hash(debug_b)] == Component.hash(splitter_b) - end - end - - describe "subnode slot wiring" do - test "builds and runs ai_agent with prompt/model slot connections" do - source = - workflow_source( - [ - step("ai_prompt_template", "ai_prompt_template", %{ - "system_prompt" => "You are a helpful assistant.", - "user_prompt" => "Hello from test", - "context" => %{} - }), - step("openai_model", "openai_model", %{ - "model" => "gpt-4.1-mini", - "temperature" => 0.2, - "max_tokens" => 400, - "credential_ref" => %{ - "id" => "cred_openai", - "provider" => "openai_api_key", - "auth_type" => "api_key", - "owner_user_id" => "user_123" - } - }), - step("ai_agent", "ai_agent", %{"mode" => "assemble_only"}), - step("debug_after", "debug", %{"label" => "After", "level" => "info"}) - ], - [ - slot_connection("c1", "ai_prompt_template", "ai_agent", "prompt"), - slot_connection("c2", "openai_model", "ai_agent", "model"), - connection("c3", "ai_agent", "debug_after") - ] - ) - - outputs = run_workflow(source, %{"name" => "John"}) - - assert is_map(outputs["ai_agent"]) - assert outputs["ai_agent"]["provider"] == "openai_api_key" - assert outputs["ai_agent"]["model"] == "gpt-4.1-mini" - assert is_list(outputs["ai_agent"]["messages"]) - assert outputs["debug_after"] == outputs["ai_agent"] - end - end - - describe "execution scope propagation" do - test "passes scope option into step runner opts" do - source = - workflow_source( - [step("debug_step", "debug", %{"label" => "Debug", "level" => "info"})], - [] - ) - - scope = %Scope{organization_id: "org_test"} - workflow = RunicAdapter.to_runic_workflow(source, execution_id: "exec_test", scope: scope) - component = Workflow.get_component!(workflow, "debug_step") - - assert {:env, [_step, opts]} = :erlang.fun_info(component.work, :env) - assert Keyword.get(opts, :scope) == scope - end - end - - defp run_workflow(source, input) do - reset_runtime_process_state() - - try do - source - |> RunicAdapter.to_runic_workflow(execution_id: "exec_test") - |> Observability.attach_all_hooks( - execution_id: "exec_test", - workflow_id: source.id, - skip_production_init: true - ) - |> Workflow.react_until_satisfied(input) - - Process.get(:fizz_accumulated_outputs, %{}) - after - reset_runtime_process_state() - end - end - - defp reset_runtime_process_state do - keys = [ - :fizz_accumulated_outputs, - :fizz_step_outputs, - :fizz_step_skipped, - :fizz_fan_out_context, - :fizz_step_events - ] - - Enum.each(keys, &Process.delete/1) - end - - defp workflow_source(steps, connections) do - %{ - id: "wf_test", - steps: steps, - connections: connections, - groups: [] - } - end - - defp step(id, type_id, config) do - %Step{ - id: id, - type_id: type_id, - name: id, - config: config, - position: %{} - } - end - - defp connection(id, source_step_id, target_step_id) do - %Connection{ - id: id, - source_step_id: source_step_id, - source_output: "main", - target_step_id: target_step_id, - target_input: "main" - } - end - - defp slot_connection(id, source_step_id, target_step_id, target_input) do - %Connection{ - id: id, - source_step_id: source_step_id, - source_output: "main", - target_step_id: target_step_id, - target_input: target_input - } - end -end diff --git a/test/fizz/runtime/steps/step_runner_test.exs b/test/fizz/runtime/steps/step_runner_test.exs deleted file mode 100644 index fc491f1..0000000 --- a/test/fizz/runtime/steps/step_runner_test.exs +++ /dev/null @@ -1,183 +0,0 @@ -defmodule Fizz.Runtime.Steps.StepRunnerTest do - use ExUnit.Case, async: true - - alias Fizz.Accounts.Scope - alias Fizz.Runtime.Steps.StepRunner - alias Fizz.Workflows.Embeds.Step - - describe "execute_with_context/3 with subnode slots" do - test "injects subnode outputs and keeps primary input isolated" do - step = %Step{ - id: "agent_step", - type_id: "ai_agent", - name: "Agent", - config: %{"mode" => "assemble_only"}, - position: %{} - } - - result = - StepRunner.execute_with_context( - step, - %{"joined" => "raw_input"}, - execution_opts(%{ - "main_input_step" => %{"question" => "What is Elixir?"}, - "model_step" => %{ - "provider" => "openai_api_key", - "credential_ref" => %{ - "id" => "cred_openai", - "provider" => "openai_api_key", - "auth_type" => "api_key", - "owner_user_id" => "user_123" - }, - "model" => "gpt-4.1-mini", - "temperature" => 0.1, - "max_tokens" => 240 - }, - "prompt_step" => %{ - "messages" => [%{"role" => "user", "content" => "Explain Elixir briefly."}] - }, - "tool_step_1" => %{"type" => "http", "name" => "docs"} - }) - ) - - assert result["_primary"] == %{"question" => "What is Elixir?"} - assert result["model"] == "gpt-4.1-mini" - assert result["provider"] == "openai_api_key" - assert result["tools"] == [%{"type" => "http", "name" => "docs"}] - assert is_list(result["messages"]) - end - - test "sets _primary to nil when no primary input edge exists" do - step = %Step{ - id: "agent_step", - type_id: "ai_agent", - name: "Agent", - config: %{"mode" => "assemble_only"}, - position: %{} - } - - result = - StepRunner.execute_with_context( - step, - %{"joined" => "slot_only"}, - execution_opts( - %{ - "model_step" => %{ - "provider" => "openai_api_key", - "credential_ref" => %{ - "id" => "cred_openai", - "provider" => "openai_api_key", - "auth_type" => "api_key", - "owner_user_id" => "user_123" - }, - "model" => "gpt-4.1-mini" - }, - "prompt_step" => %{"messages" => [%{"role" => "user", "content" => "Hi"}]} - }, - primary_parents: [] - ) - ) - - assert result["_primary"] == nil - end - - test "propagates scope into ai_agent provider_chat execution context" do - step = %Step{ - id: "agent_step", - type_id: "ai_agent", - name: "Agent", - config: %{"mode" => "provider_chat"}, - position: %{} - } - - scope = %Scope{organization_id: "org_test"} - - assert {:step_error, "agent_step", {:unsupported_provider, "custom_provider"}} = - catch_throw( - StepRunner.execute_with_context( - step, - %{"joined" => "raw_input"}, - execution_opts( - %{ - "main_input_step" => %{"question" => "What is Elixir?"}, - "model_step" => %{ - "provider" => "custom_provider", - "credential_ref" => %{ - "id" => "cred_openai", - "provider" => "openai_api_key", - "auth_type" => "api_key", - "owner_user_id" => "user_123" - }, - "model" => "custom-model" - }, - "prompt_step" => %{ - "messages" => [%{"role" => "user", "content" => "Explain Elixir."}] - } - }, - scope: scope - ) - ) - ) - end - end - - describe "execute_with_context/3 primary input resolution" do - test "uses fact input for one-parent steps without slot bindings" do - step = %Step{ - id: "debug_step", - type_id: "debug", - name: "Debug", - config: %{"label" => "Debug", "level" => "info"}, - position: %{} - } - - result = - StepRunner.execute_with_context( - step, - %{"item" => 2}, - execution_id: "exec_test", - workflow_id: "wf_test", - step_outputs: %{ - "splitter_step" => [%{"item" => 1}, %{"item" => 2}] - }, - upstream_lookup: %{ - "debug_step" => ["splitter_step"] - }, - primary_parent_lookup: %{ - "debug_step" => ["splitter_step"] - }, - slot_bindings: %{ - "debug_step" => %{} - } - ) - - assert result == %{"item" => 2} - end - end - - defp execution_opts(step_outputs, opts \\ []) do - primary_parents = Keyword.get(opts, :primary_parents, ["main_input_step"]) - extra_opts = Keyword.drop(opts, [:primary_parents]) - - base_opts = [ - execution_id: "exec_test", - workflow_id: "wf_test", - step_outputs: step_outputs, - upstream_lookup: %{ - "agent_step" => Map.keys(step_outputs) - }, - primary_parent_lookup: %{ - "agent_step" => primary_parents - }, - slot_bindings: %{ - "agent_step" => %{ - "model" => ["model_step"], - "prompt" => ["prompt_step"], - "tools" => ["tool_step_1"] - } - } - ] - - base_opts ++ extra_opts - end -end diff --git a/test/fizz_web/live/workflow_execution_live_test.exs b/test/fizz_web/live/workflow_execution_live_test.exs index 21aa1b3..da99da8 100644 --- a/test/fizz_web/live/workflow_execution_live_test.exs +++ b/test/fizz_web/live/workflow_execution_live_test.exs @@ -70,7 +70,7 @@ defmodule FizzWeb.WorkflowExecutionLiveTest do assert has_element?(view, "#workflow-create-button") end - test "workflow index creates a workflow and navigates to edit", %{ + test "workflow index creates a workflow and navigates to workflow show", %{ conn: conn, workspace: workspace } do @@ -81,8 +81,7 @@ defmodule FizzWeb.WorkflowExecutionLiveTest do |> element("#workflow-create-button") |> render_click() - assert to =~ "/workspaces/#{workspace.id}/workflows/" - assert String.ends_with?(to, "/edit") + assert to =~ ~r{^/workspaces/#{workspace.id}/workflows/[0-9a-f-]+$} end test "workflow show renders workspace-scoped navigation links", %{ @@ -97,15 +96,12 @@ defmodule FizzWeb.WorkflowExecutionLiveTest do assert has_element?(view, "a[href='/workspaces/#{workspace.id}/workflows']") - assert has_element?( - view, - "a[href='/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit']" - ) - assert has_element?( view, "a[href='/workspaces/#{workspace.id}/workflows/#{workflow.id}/execution/#{execution.id}']" ) + + refute has_element?(view, "a", "Run Workflow") end test "execution show renders key sections and workflow links", %{ @@ -125,8 +121,7 @@ defmodule FizzWeb.WorkflowExecutionLiveTest do assert has_element?(view, "#execution-back-link") assert has_element?(view, "#execution-workflow-link") - assert has_element?(view, "#execution-debug-link") - assert has_element?(view, "#execution-edit-link") + assert has_element?(view, "span", "Runtime unavailable") assert has_element?(view, "#execution-step-list") end diff --git a/test/fizz_web/live/workflow_live/edit_add_step_auto_connect_test.exs b/test/fizz_web/live/workflow_live/edit_add_step_auto_connect_test.exs deleted file mode 100644 index df44f3f..0000000 --- a/test/fizz_web/live/workflow_live/edit_add_step_auto_connect_test.exs +++ /dev/null @@ -1,255 +0,0 @@ -defmodule FizzWeb.WorkflowLive.EditAddStepAutoConnectTest do - use FizzWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership} - alias Fizz.Collaboration.EditSession.Server - alias Fizz.Repo - alias Fizz.Workflows - - defmodule WorkOSHTTPStub do - def request(opts) do - case {opts[:method], opts[:url]} do - {:get, "/user_management/organization_memberships"} -> - {:ok, - %Req.Response{ - status: 200, - body: %{ - "data" => [ - %{ - "status" => "active", - "role" => %{"slug" => "owner"} - } - ] - } - }} - - _ -> - {:ok, %Req.Response{status: 200, body: %{}}} - end - end - end - - setup %{conn: conn} do - previous_http_client = Application.get_env(:fizz, :workos_http_client_module) - previous_workos_client = Application.get_env(:workos, WorkOS.Client) - - Application.put_env(:fizz, :workos_http_client_module, WorkOSHTTPStub) - - Application.put_env(:workos, WorkOS.Client, - api_key: "test_api_key", - client_id: "test_client_id", - client: Fizz.Accounts.WorkOS.ReqClient - ) - - on_exit(fn -> - Application.put_env(:fizz, :workos_http_client_module, previous_http_client) - - case previous_workos_client do - nil -> Application.delete_env(:workos, WorkOS.Client) - value -> Application.put_env(:workos, WorkOS.Client, value) - end - end) - - user = Fizz.AccountsFixtures.user_fixture() - workspace = workspace_fixture!(user) - scope = scoped_workspace_access(user, workspace) - workflow = workflow_fixture!(scope) - conn = log_in_user(conn, user) - - %{conn: conn, workspace: workspace, workflow: workflow} - end - - test "add_step with source auto_connect links existing source to new step", %{ - conn: conn, - workspace: workspace, - workflow: workflow - } do - {:ok, view, _html} = - live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit") - - initial_step_ids = MapSet.new(["source", "agent"]) - - render_hook(view, "editor_command", %{ - "type" => "add_step", - "payload" => %{ - "type_id" => "math", - "position" => %{"x" => 320, "y" => 240}, - "auto_connect" => %{ - "source_step_id" => "source", - "source_output" => "main" - } - } - }) - - _ = :sys.get_state(view.pid) - - assert {:ok, %{type: :full_sync, draft: draft}} = Server.get_sync_state(workflow.id) - - new_step_id = draft |> step_ids() |> find_new_step_id(initial_step_ids) - assert is_binary(new_step_id) - - assert has_connection?(draft, fn conn -> - connection_field(conn, :source_step_id) == "source" and - connection_field(conn, :target_step_id) == new_step_id and - connection_field(conn, :source_output) == "main" and - connection_field(conn, :target_input) == "main" - end) - end - - test "add_step with target slot auto_connect links new subnode into target slot", %{ - conn: conn, - workspace: workspace, - workflow: workflow - } do - {:ok, view, _html} = - live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit") - - initial_step_ids = MapSet.new(["source", "agent"]) - - render_hook(view, "editor_command", %{ - "type" => "add_step", - "payload" => %{ - "type_id" => "openai_model", - "position" => %{"x" => 420, "y" => 180}, - "auto_connect" => %{ - "target_step_id" => "agent", - "target_input" => "model" - } - } - }) - - _ = :sys.get_state(view.pid) - - assert {:ok, %{type: :full_sync, draft: draft}} = Server.get_sync_state(workflow.id) - - new_step_id = draft |> step_ids() |> find_new_step_id(initial_step_ids) - assert is_binary(new_step_id) - - assert has_connection?(draft, fn conn -> - connection_field(conn, :source_step_id) == new_step_id and - connection_field(conn, :target_step_id) == "agent" and - connection_field(conn, :source_output) == "main" and - connection_field(conn, :target_input) == "model" - end) - end - - test "invalid slot auto_connect keeps new step and shows warning flash", %{ - conn: conn, - workspace: workspace, - workflow: workflow - } do - {:ok, view, _html} = - live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit") - - initial_step_ids = MapSet.new(["source", "agent"]) - - render_hook(view, "editor_command", %{ - "type" => "add_step", - "payload" => %{ - "type_id" => "math", - "position" => %{"x" => 480, "y" => 260}, - "auto_connect" => %{ - "target_step_id" => "agent", - "target_input" => "model" - } - } - }) - - _ = :sys.get_state(view.pid) - - assert {:ok, %{type: :full_sync, draft: draft}} = Server.get_sync_state(workflow.id) - - new_step_id = draft |> step_ids() |> find_new_step_id(initial_step_ids) - assert is_binary(new_step_id) - - refute has_connection?(draft, fn conn -> - connection_field(conn, :source_step_id) == new_step_id and - connection_field(conn, :target_step_id) == "agent" and - connection_field(conn, :target_input) == "model" - end) - - assert render(view) =~ "Step added but auto-connect failed" - end - - defp step_ids(draft) do - draft.steps - |> List.wrap() - |> Enum.map(fn step -> connection_field(step, :id) end) - |> Enum.reject(&is_nil/1) - end - - defp find_new_step_id(step_ids, initial_step_ids) do - Enum.find(step_ids, fn step_id -> not MapSet.member?(initial_step_ids, step_id) end) - end - - defp has_connection?(draft, predicate) do - draft.connections - |> List.wrap() - |> Enum.any?(predicate) - end - - defp connection_field(map, key) when is_map(map) do - Map.get(map, key) || Map.get(map, Atom.to_string(key)) - end - - defp workspace_fixture!(user) do - unique = System.unique_integer([:positive]) - - workspace = - %Workspace{} - |> Workspace.changeset(%{ - name: "LiveView Auto Connect Workspace #{unique}", - slug: "liveview-auto-connect-workspace-#{unique}", - workos_organization_id: "org_#{unique}" - }) - |> Repo.insert!() - - %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id} - |> WorkspaceMembership.changeset(%{role: :admin}) - |> Repo.insert!() - - workspace - end - - defp scoped_workspace_access(user, workspace) do - Scope.for_user(user) - |> Scope.with_organization_id(workspace.workos_organization_id) - |> Scope.with_organization_role(:owner) - |> Scope.with_workspace(workspace) - |> Scope.with_workspace_role(:admin) - end - - defp workflow_fixture!(scope) do - {:ok, workflow} = - Workflows.create_workflow(scope, %{ - name: "LiveView Auto Connect #{System.unique_integer([:positive])}", - description: "auto connect test" - }) - - {:ok, _draft} = - Workflows.update_workflow_draft(scope, workflow, %{ - steps: [ - %{ - id: "source", - type_id: "math", - name: "Source", - config: %{}, - position: %{x: 80, y: 120} - }, - %{ - id: "agent", - type_id: "ai_agent", - name: "Agent", - config: %{}, - position: %{x: 280, y: 120} - } - ], - connections: [], - groups: [] - }) - - workflow - end -end diff --git a/test/fizz_web/live/workflow_live/edit_commit_drag_layout_test.exs b/test/fizz_web/live/workflow_live/edit_commit_drag_layout_test.exs deleted file mode 100644 index 3201dcb..0000000 --- a/test/fizz_web/live/workflow_live/edit_commit_drag_layout_test.exs +++ /dev/null @@ -1,194 +0,0 @@ -defmodule FizzWeb.WorkflowLive.EditCommitDragLayoutTest do - use FizzWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership} - alias Fizz.Collaboration.EditSession.Server - alias Fizz.Repo - alias Fizz.Workflows - - defmodule WorkOSHTTPStub do - def request(opts) do - case {opts[:method], opts[:url]} do - {:get, "/user_management/organization_memberships"} -> - {:ok, - %Req.Response{ - status: 200, - body: %{ - "data" => [ - %{ - "status" => "active", - "role" => %{"slug" => "owner"} - } - ] - } - }} - - _ -> - {:ok, %Req.Response{status: 200, body: %{}}} - end - end - end - - setup %{conn: conn} do - previous_http_client = Application.get_env(:fizz, :workos_http_client_module) - previous_workos_client = Application.get_env(:workos, WorkOS.Client) - - Application.put_env(:fizz, :workos_http_client_module, WorkOSHTTPStub) - - Application.put_env(:workos, WorkOS.Client, - api_key: "test_api_key", - client_id: "test_client_id", - client: Fizz.Accounts.WorkOS.ReqClient - ) - - on_exit(fn -> - Application.put_env(:fizz, :workos_http_client_module, previous_http_client) - - case previous_workos_client do - nil -> Application.delete_env(:workos, WorkOS.Client) - value -> Application.put_env(:workos, WorkOS.Client, value) - end - end) - - user = Fizz.AccountsFixtures.user_fixture() - workspace = workspace_fixture!(user) - scope = scoped_workspace_access(user, workspace) - workflow = workflow_fixture!(scope) - conn = log_in_user(conn, user) - - %{conn: conn, user: user, workspace: workspace, scope: scope, workflow: workflow} - end - - test "editor_command commit_drag_layout applies one coherent draft commit", %{ - conn: conn, - workspace: workspace, - workflow: workflow - } do - {:ok, view, _html} = - live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit") - - payload = %{ - "txn_id" => "txn-liveview-1", - "base_seq" => 0, - "groups" => [ - %{ - "group_id" => "group_a", - "position" => %{"x" => 130, "y" => 140, "width" => 430, "height" => 310} - } - ], - "step_positions" => %{ - "step_a" => %{"x" => 45, "y" => 55}, - "step_b" => %{"x" => 200, "y" => 210} - }, - "group_id_by_step_id" => %{ - "step_b" => nil - } - } - - render_hook(view, "editor_command", %{ - "type" => "commit_drag_layout", - "payload" => payload - }) - - _ = :sys.get_state(view.pid) - - assert {:ok, %{type: :full_sync, draft: draft, seq: seq}} = Server.get_sync_state(workflow.id) - assert seq > 0 - assert group_position_for(draft, "group_a") == %{x: 130, y: 140, width: 430, height: 310} - assert step_position_for(draft, "step_a") == %{x: 45, y: 55} - assert step_position_for(draft, "step_b") == %{x: 200, y: 210} - assert group_step_ids_for(draft, "group_a") == ["step_a"] - end - - defp group_position_for(draft, group_id) do - draft.groups - |> List.wrap() - |> Enum.find(fn group -> Map.get(group, :id) == group_id end) - |> Map.get(:position) - end - - defp group_step_ids_for(draft, group_id) do - draft.groups - |> List.wrap() - |> Enum.find(fn group -> Map.get(group, :id) == group_id end) - |> Map.get(:step_ids) - end - - defp step_position_for(draft, step_id) do - draft.steps - |> List.wrap() - |> Enum.find(fn step -> Map.get(step, :id) == step_id end) - |> Map.get(:position) - end - - defp workspace_fixture!(user) do - unique = System.unique_integer([:positive]) - - workspace = - %Workspace{} - |> Workspace.changeset(%{ - name: "LiveView Edit Workspace #{unique}", - slug: "liveview-edit-workspace-#{unique}", - workos_organization_id: "org_#{unique}" - }) - |> Repo.insert!() - - %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id} - |> WorkspaceMembership.changeset(%{role: :admin}) - |> Repo.insert!() - - workspace - end - - defp scoped_workspace_access(user, workspace) do - Scope.for_user(user) - |> Scope.with_organization_id(workspace.workos_organization_id) - |> Scope.with_organization_role(:owner) - |> Scope.with_workspace(workspace) - |> Scope.with_workspace_role(:admin) - end - - defp workflow_fixture!(scope) do - {:ok, workflow} = - Workflows.create_workflow(scope, %{ - name: "LiveView Commit Layout #{System.unique_integer([:positive])}", - description: "liveview test" - }) - - {:ok, _draft} = - Workflows.update_workflow_draft(scope, workflow, %{ - steps: [ - %{ - id: "step_a", - type_id: "math", - name: "Step A", - config: %{}, - position: %{x: 60, y: 60} - }, - %{ - id: "step_b", - type_id: "math", - name: "Step B", - config: %{}, - position: %{x: 140, y: 110} - } - ], - connections: [], - groups: [ - %{ - id: "group_a", - name: "Group A", - step_ids: ["step_a", "step_b"], - output_step_id: "step_a", - position: %{x: 100, y: 100, width: 320, height: 240}, - color: nil, - collapsed: false - } - ] - }) - - workflow - end -end diff --git a/test/fizz_web/live/workflow_live/edit_step_projection_test.exs b/test/fizz_web/live/workflow_live/edit_step_projection_test.exs deleted file mode 100644 index 0acb58a..0000000 --- a/test/fizz_web/live/workflow_live/edit_step_projection_test.exs +++ /dev/null @@ -1,111 +0,0 @@ -defmodule FizzWeb.WorkflowLive.EditStepProjectionTest do - use ExUnit.Case, async: true - - alias Fizz.Runtime.Serializer - alias FizzWeb.WorkflowLive.Edit.EditStepProjection - - test "preserves a failed summary row when itemized cancellations arrive" do - execution_id = Ecto.UUID.generate() - - initial = [ - running_step_execution(execution_id, 0), - running_step_execution(execution_id, 1) - ] - - updated = - initial - |> EditStepProjection.apply_event(execution_id, :step_failed, %{ - execution_id: execution_id, - step_id: "fan_out_step", - status: :failed, - completed_at: ~U[2026-03-01 00:00:02Z], - error: %{"type" => "step_failure"} - }) - |> EditStepProjection.apply_event(execution_id, :step_cancelled, %{ - execution_id: execution_id, - step_id: "fan_out_step", - status: :cancelled, - item_index: 0, - items_total: 2, - completed_at: ~U[2026-03-01 00:00:03Z] - }) - |> EditStepProjection.apply_event(execution_id, :step_cancelled, %{ - execution_id: execution_id, - step_id: "fan_out_step", - status: :cancelled, - item_index: 1, - items_total: 2, - completed_at: ~U[2026-03-01 00:00:03Z] - }) - - assert %{status: :failed, item_index: nil} = - Enum.find(updated, &is_nil(Map.get(&1, :item_index))) - - assert 2 == Enum.count(updated, &(Map.get(&1, :status) == :cancelled)) - refute Enum.any?(updated, &(Map.get(&1, :status) == :running)) - end - - test "keeps failed status when a later cancelled event targets the same summary row" do - execution_id = Ecto.UUID.generate() - - updated = - [ - %{ - id: "#{execution_id}:fan_out_step:1", - execution_id: execution_id, - step_id: "fan_out_step", - status: :failed, - attempt: 1, - item_index: nil, - error: %{"type" => "step_failure"}, - completed_at: ~U[2026-03-01 00:00:02Z], - metadata: %{} - } - ] - |> EditStepProjection.apply_event(execution_id, :step_cancelled, %{ - execution_id: execution_id, - step_id: "fan_out_step", - status: :cancelled, - completed_at: ~U[2026-03-01 00:00:03Z] - }) - - assert [%{status: :failed}] = updated - end - - test "normalizes sanitized runtime timestamps for fan-out item events" do - execution_id = Ecto.UUID.generate() - started_at = ~U[2026-03-01 00:00:00.123456Z] - completed_at = ~U[2026-03-01 00:00:02.654321Z] - - [step_execution] = - EditStepProjection.apply_event([], execution_id, :step_completed, %{ - "execution_id" => execution_id, - "step_id" => "fan_out_step", - "status" => "completed", - "item_index" => 0, - "items_total" => 2, - "started_at" => Serializer.sanitize(started_at), - "completed_at" => Serializer.sanitize(completed_at), - "duration_us" => 2_530_865 - }) - - assert step_execution.started_at == DateTime.to_iso8601(started_at) - assert step_execution.completed_at == DateTime.to_iso8601(completed_at) - assert step_execution.duration_us == 2_530_865 - end - - defp running_step_execution(execution_id, item_index) do - %{ - id: "#{execution_id}:fan_out_step:#{item_index}:1", - execution_id: execution_id, - step_id: "fan_out_step", - step_type_id: "fan_out", - status: :running, - attempt: 1, - item_index: item_index, - items_total: 2, - started_at: ~U[2026-03-01 00:00:00Z], - metadata: %{} - } - end -end diff --git a/test/fizz_web/live/workflow_live_test.exs b/test/fizz_web/live/workflow_live_test.exs index 822e936..94e1865 100644 --- a/test/fizz_web/live/workflow_live_test.exs +++ b/test/fizz_web/live/workflow_live_test.exs @@ -18,22 +18,6 @@ defmodule FizzWeb.WorkflowLiveTest do live(conn, ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}") end - test "workflow edit requires authentication", %{conn: conn} do - workspace_id = Ecto.UUID.generate() - workflow_id = Ecto.UUID.generate() - - assert {:error, {:redirect, %{to: "/auth/workos"}}} = - live(conn, ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/edit") - end - - test "workflow revisions requires authentication", %{conn: conn} do - workspace_id = Ecto.UUID.generate() - workflow_id = Ecto.UUID.generate() - - assert {:error, {:redirect, %{to: "/auth/workos"}}} = - live(conn, ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/revisions") - end - test "execution show requires authentication", %{conn: conn} do workspace_id = Ecto.UUID.generate() workflow_id = Ecto.UUID.generate() From cff091862968a1213f4358c1165cfb8a15b28839 Mon Sep 17 00:00:00 2001 From: Galad Dirie Date: Tue, 10 Mar 2026 17:39:41 -0400 Subject: [PATCH 002/135] remove executions layer --- lib/fizz/accounts/scope.ex | 39 - lib/fizz/executions.ex | 1241 ----------------- lib/fizz/executions/events.ex | 185 --- lib/fizz/executions/execution.ex | 280 ---- lib/fizz/executions/pub_sub.ex | 153 -- lib/fizz/executions/step_execution.ex | 223 --- lib/fizz/runtime/expression.ex | 82 +- lib/fizz/runtime/expression/context.ex | 144 +- lib/fizz/steps/definition.ex | 2 +- lib/fizz/steps/executors/behaviour.ex | 12 +- lib/fizz/workflows.ex | 44 +- lib/fizz/workflows/embeds/step.ex | 3 +- lib/fizz_web/live/execution_live/index.ex | 6 - lib/fizz_web/live/execution_live/show.ex | 561 -------- .../live/execution_live/show_presenter.ex | 310 ---- lib/fizz_web/live/workflow_live/paths.ex | 3 - lib/fizz_web/live/workflow_live/show.ex | 100 -- lib/fizz_web/router.ex | 4 - test/fizz/accounts/scope_test.exs | 15 - .../cancel_active_step_executions_test.exs | 128 -- test/fizz/executions_test.exs | 62 - test/fizz_web/live/workflow_live_test.exs | 12 - ...t.exs => workflow_workspace_live_test.exs} | 67 +- 23 files changed, 67 insertions(+), 3609 deletions(-) delete mode 100644 lib/fizz/executions.ex delete mode 100644 lib/fizz/executions/events.ex delete mode 100644 lib/fizz/executions/execution.ex delete mode 100644 lib/fizz/executions/pub_sub.ex delete mode 100644 lib/fizz/executions/step_execution.ex delete mode 100644 lib/fizz_web/live/execution_live/index.ex delete mode 100644 lib/fizz_web/live/execution_live/show.ex delete mode 100644 lib/fizz_web/live/execution_live/show_presenter.ex delete mode 100644 test/fizz/executions/cancel_active_step_executions_test.exs delete mode 100644 test/fizz/executions_test.exs rename test/fizz_web/live/{workflow_execution_live_test.exs => workflow_workspace_live_test.exs} (67%) diff --git a/lib/fizz/accounts/scope.ex b/lib/fizz/accounts/scope.ex index ce4d3dd..80821aa 100644 --- a/lib/fizz/accounts/scope.ex +++ b/lib/fizz/accounts/scope.ex @@ -7,7 +7,6 @@ defmodule Fizz.Accounts.Scope do """ alias Fizz.Accounts.{User, Workspace} - alias Fizz.Executions.Execution alias Fizz.Workflows.Workflow @organization_roles [:owner, :admin, :member] @@ -149,44 +148,6 @@ defmodule Fizz.Accounts.Scope do @spec owns_workflow?(t() | nil, Workflow.t() | map()) :: boolean() def owns_workflow?(scope, workflow), do: can_edit_workflow?(scope, workflow) - @doc """ - Whether scope can view an execution through its workflow access. - """ - @spec can_view_execution?(t() | nil, Execution.t() | map()) :: boolean() - def can_view_execution?(scope, %Execution{workflow: %Workflow{} = workflow}), - do: can_view_workflow?(scope, workflow) - - def can_view_execution?(scope, %{workflow: %{workspace_id: _workspace_id} = workflow}), - do: can_view_workflow?(scope, workflow) - - def can_view_execution?(_scope, _execution), do: false - - @doc """ - Whether scope can create executions for a workflow. - - Nil scope is only allowed for production executions, with trigger restrictions - enforced in the execution context. - """ - @spec can_create_execution?(t() | nil, Workflow.t() | map(), Execution.execution_type() | nil) :: - boolean() - def can_create_execution?(%__MODULE__{} = scope, %Workflow{} = workflow, execution_type) - when execution_type in [:production, :preview, :partial], - do: can_edit_workflow?(scope, workflow) - - def can_create_execution?( - %__MODULE__{} = scope, - %{workspace_id: _workspace_id} = workflow, - execution_type - ) - when execution_type in [:production, :preview, :partial], - do: can_edit_workflow?(scope, workflow) - - def can_create_execution?(nil, %{workspace_id: workspace_id}, :production) - when is_binary(workspace_id) and byte_size(workspace_id) > 0, - do: true - - def can_create_execution?(_scope, _workflow, _execution_type), do: false - defp same_workspace?(%__MODULE__{workspace: %Workspace{id: scope_workspace_id}}, %{ workspace_id: workflow_workspace_id }) diff --git a/lib/fizz/executions.ex b/lib/fizz/executions.ex deleted file mode 100644 index c7ddccf..0000000 --- a/lib/fizz/executions.ex +++ /dev/null @@ -1,1241 +0,0 @@ -defmodule Fizz.Executions do - @moduledoc """ - Context for managing workflow executions and step executions. - - Provides functions to create, read, update, and manage executions, - track execution status, and handle step-level execution details. - """ - - import Ecto.Query, warn: false - require Logger - alias Fizz.Repo - - alias Fizz.Executions.{Execution, Events, StepExecution} - alias Fizz.Workflows.Workflow - alias Fizz.Accounts.Scope - alias Fizz.Serializer - - @active_step_statuses [:pending, :queued, :running] - @active_status_rank %{pending: 0, queued: 1, running: 2} - @terminal_status_priority %{failed: 3, cancelled: 2, completed: 1, skipped: 0} - @status_sort_rank %{ - pending: 0, - queued: 1, - running: 2, - completed: 3, - skipped: 4, - cancelled: 5, - failed: 6 - } - @max_timestamp 9_999_999_999_999_999 - @internal_trigger_types [:schedule, :webhook, :event] - - @type execution_params :: %{ - required(:workflow_id) => Ecto.UUID.t(), - required(:trigger) => map(), - optional(:execution_type) => Execution.execution_type(), - optional(:metadata) => map(), - optional(:triggered_by_user_id) => Ecto.UUID.t() - } - - @type step_execution_params :: %{ - required(:execution_id) => Ecto.UUID.t(), - required(:step_id) => String.t(), - required(:step_type_id) => String.t(), - optional(:input_data) => map(), - optional(:metadata) => map() - } - - @doc """ - Lists executions accessible to the given scope. - - Returns executions for workflows in the active workspace. - """ - @spec list_executions(Scope.t() | nil) :: [Execution.t()] - def list_executions(%Scope{workspace: %{id: workspace_id}} = scope) - when is_binary(workspace_id) do - case Scope.can_view_workflow?(scope, %Workflow{workspace_id: workspace_id}) do - true -> - query = - from e in Execution, - join: w in Workflow, - on: e.workflow_id == w.id, - where: w.workspace_id == ^workspace_id, - order_by: [desc: e.inserted_at], - limit: 100 - - Repo.all(query) - - false -> - [] - end - end - - def list_executions(_scope), do: [] - - @doc """ - Lists executions for a specific workflow, checking access permissions. - - Returns executions if the user has access to the workflow, empty list otherwise. - """ - @spec list_workflow_executions(Scope.t() | nil, Workflow.t(), keyword()) :: [Execution.t()] - def list_workflow_executions(scope, %Workflow{} = workflow, opts \\ []) do - limit = Keyword.get(opts, :limit, 50) - offset = Keyword.get(opts, :offset, 0) - - if Scope.can_view_workflow?(scope, workflow) do - Repo.all( - from e in Execution, - where: e.workflow_id == ^workflow.id, - order_by: [desc: e.inserted_at], - limit: ^limit, - offset: ^offset - ) - else - [] - end - end - - @doc """ - Gets a single execution by ID, checking access permissions. - - Returns `{:ok, execution}` if the user has access, `{:error, :not_found}` otherwise. - """ - @spec get_execution(Scope.t() | nil, String.t() | Ecto.UUID.t()) :: - {:ok, Execution.t()} | {:error, :not_found} - def get_execution(scope, id) do - case Repo.get(Execution, id) |> Repo.preload([:workflow, :triggered_by_user]) do - nil -> - {:error, :not_found} - - %Execution{} = execution -> - if Scope.can_view_workflow?(scope, execution.workflow) do - {:ok, execution} - else - {:error, :not_found} - end - end - end - - @doc """ - Gets an execution with its step executions preloaded. - - Returns `{:ok, execution}` with step executions loaded, or `{:error, :not_found}`. - """ - @spec get_execution_with_steps(Scope.t() | nil, String.t() | Ecto.UUID.t()) :: - {:ok, Execution.t()} | {:error, :not_found} - def get_execution_with_steps(scope, id) do - case Repo.get(Execution, id) - |> Repo.preload([ - :triggered_by_user, - :step_executions, - workflow: [:published_version, :draft] - ]) do - nil -> - {:error, :not_found} - - %Execution{} = execution -> - if Scope.can_view_workflow?(scope, execution.workflow) do - {:ok, attach_step_execution_durations(execution)} - else - {:error, :not_found} - end - end - end - - defp attach_step_execution_durations(%Execution{} = execution) do - step_executions = - Enum.map(execution.step_executions || [], fn step_execution -> - %{step_execution | duration_us: StepExecution.duration_us(step_execution)} - end) - - %{execution | step_executions: step_executions} - end - - @doc """ - Creates a new execution for a workflow. - - Returns `{:ok, execution}` if successful, `{:error, changeset}` otherwise. - """ - @spec create_execution(Scope.t() | nil, execution_params()) :: - {:ok, Execution.t()} - | {:error, - Ecto.Changeset.t() | :workflow_not_found | :workflow_not_published | :access_denied} - def create_execution(scope, attrs) do - workflow_id = fetch_attr(attrs, :workflow_id) - - execution_type = - attrs - |> fetch_attr(:execution_type) - |> normalize_execution_type() - |> case do - nil -> :production - normalized_type -> normalized_type - end - - case Repo.get(Workflow, workflow_id) do - nil -> - {:error, :workflow_not_found} - - workflow -> - can_create = Scope.can_create_execution?(scope, workflow, execution_type) - - internal_scope_allowed? = - internal_scope_execution_allowed?(scope, execution_type, fetch_attr(attrs, :trigger)) - - cond do - not can_create -> - {:error, :access_denied} - - not internal_scope_allowed? -> - {:error, :access_denied} - - true -> - # Get the published version for production executions - published_version_id = workflow.published_version_id - - cond do - not is_nil(published_version_id) -> - attrs = - attrs - |> maybe_put_triggered_by_user(scope) - - %Execution{} - |> Execution.changeset(attrs) - |> Repo.insert() - - execution_type in [:preview, :partial] -> - attrs = maybe_put_triggered_by_user(attrs, scope) - - %Execution{} - |> Execution.changeset(attrs) - |> Repo.insert() - - true -> - {:error, :workflow_not_published} - end - end - end - end - - defp maybe_put_triggered_by_user(attrs, scope) do - if scope && scope.user do - Map.put(attrs, :triggered_by_user_id, scope.user.id) - else - attrs - end - end - - defp fetch_attr(attrs, key) when is_map(attrs) do - Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) - end - - defp fetch_attr(_attrs, _key), do: nil - - defp normalize_execution_type(type) when type in [:production, :preview, :partial], do: type - defp normalize_execution_type("production"), do: :production - defp normalize_execution_type("preview"), do: :preview - defp normalize_execution_type("partial"), do: :partial - defp normalize_execution_type(_type), do: nil - - defp internal_scope_execution_allowed?(nil, :production, trigger), - do: internal_trigger?(trigger) - - defp internal_scope_execution_allowed?(nil, _execution_type, _trigger), do: false - defp internal_scope_execution_allowed?(_scope, _execution_type, _trigger), do: true - - defp internal_trigger?(%{type: type}), - do: normalize_trigger_type(type) in @internal_trigger_types - - defp internal_trigger?(%{"type" => type}), - do: normalize_trigger_type(type) in @internal_trigger_types - - defp internal_trigger?(_), do: false - - defp can_manage_execution?(scope, %Workflow{} = workflow), - do: Scope.can_edit_workflow?(scope, workflow) - - defp can_manage_execution?(_scope, _workflow), do: false - - defp normalize_trigger_type(type) when type in [:manual, :schedule, :webhook, :event], do: type - defp normalize_trigger_type("manual"), do: :manual - defp normalize_trigger_type("schedule"), do: :schedule - defp normalize_trigger_type("webhook"), do: :webhook - defp normalize_trigger_type("event"), do: :event - defp normalize_trigger_type(_type), do: nil - - @doc """ - Updates an execution status. - - Returns `{:ok, execution}` if successful, `{:error, changeset | :access_denied}` otherwise. - """ - @spec update_execution_status(Scope.t() | nil, Execution.t(), Execution.status(), keyword()) :: - {:ok, Execution.t()} | {:error, Ecto.Changeset.t() | :access_denied} - def update_execution_status(scope, %Execution{} = execution, status, opts \\ []) do - # Ensure workflow is loaded - execution = Repo.preload(execution, :workflow) - - if can_manage_execution?(scope, execution.workflow) do - updates = %{status: status} - - # Add timestamps based on status - updates = - case status do - :running -> - Map.put(updates, :started_at, DateTime.utc_now()) - - s when s in [:completed, :failed, :cancelled, :timeout] -> - Map.put(updates, :completed_at, DateTime.utc_now()) - - _ -> - updates - end - - # Add error information if provided - updates = - if Keyword.has_key?(opts, :error) do - Map.put(updates, :error, Execution.format_error(Keyword.get(opts, :error))) - else - updates - end - - # Add output if provided - updates = - if Keyword.has_key?(opts, :output) do - Map.put(updates, :output, Keyword.get(opts, :output)) - else - updates - end - - # Add context if provided (allows preview executions to persist outputs) - updates = - if Keyword.has_key?(opts, :context) do - Map.put(updates, :context, Keyword.get(opts, :context)) - else - updates - end - - execution - |> Execution.changeset(updates) - |> Repo.update() - else - {:error, :access_denied} - end - end - - @doc """ - Cancels an execution. - - Returns `{:ok, execution}` if successful, `{:error, reason}` otherwise. - """ - @spec cancel_execution(Scope.t() | nil, Execution.t()) :: - {:ok, Execution.t()} | {:error, :access_denied | :already_terminal} - def cancel_execution(scope, %Execution{} = execution) do - if Execution.terminal?(execution) do - {:error, :already_terminal} - else - case update_execution_status(scope, execution, :cancelled) do - {:ok, updated_execution} -> - Events.emit( - :execution_cancelled, - updated_execution.id, - %{status: :cancelled}, - workflow_id: updated_execution.workflow_id, - source: :executions - ) - - # Cancel active steps - cancel_active_step_executions(updated_execution.id) - - {:ok, updated_execution} - - {:error, reason} -> - {:error, reason} - end - end - end - - @doc """ - Cancels all active step executions for an execution. - - An active step is one with status :pending, :queued, or :running. - All matched steps will be transitioned to :cancelled. - """ - @spec cancel_active_step_executions(Ecto.UUID.t()) :: {integer(), nil | [term()]} - def cancel_active_step_executions(execution_id) do - now = DateTime.utc_now() - - query = - from se in StepExecution, - where: se.execution_id == ^execution_id and se.status in ^@active_step_statuses - - # We use update_all for efficiency, but we need to broadcast events. - # In a high-scale system, we might just broadcast one "execution_cancelled" event - # and let the UI handle it, but for granularity, we'll fetch and broadcast. - active_steps = Repo.all(query) - - result = - Repo.update_all(query, - set: [ - status: :cancelled, - completed_at: now, - updated_at: now - ] - ) - - Task.start(fn -> - Enum.each(active_steps, fn step -> - payload = %{ - id: step.id, - execution_id: execution_id, - step_id: step.step_id, - status: :cancelled, - completed_at: now, - step_type_id: step.step_type_id, - item_index: step.item_index, - items_total: step.items_total, - attempt: step.attempt, - retry_of_id: step.retry_of_id, - queued_at: step.queued_at, - started_at: step.started_at, - metadata: step.metadata - } - - Fizz.Executions.PubSub.broadcast_step(:step_cancelled, execution_id, nil, payload) - end) - end) - - result - end - - @doc """ - Lists step executions for an execution. - - Returns step executions ordered by insertion time. - """ - @spec list_step_executions(Scope.t() | nil, Execution.t()) :: [StepExecution.t()] - def list_step_executions(scope, %Execution{} = execution) do - # Ensure workflow is loaded - execution = Repo.preload(execution, :workflow) - - if Scope.can_view_workflow?(scope, execution.workflow) do - Repo.all( - from se in StepExecution, - where: se.execution_id == ^execution.id, - order_by: [asc: se.started_at, asc: se.inserted_at] - ) - else - [] - end - end - - @doc """ - Gets a step execution by ID, checking access permissions. - - Returns `{:ok, step_execution}` if the user has access, `{:error, :not_found}` otherwise. - """ - @spec get_step_execution(Scope.t() | nil, String.t() | Ecto.UUID.t()) :: - {:ok, StepExecution.t()} | {:error, :not_found} - def get_step_execution(scope, id) do - case Repo.get(StepExecution, id) |> Repo.preload(execution: :workflow) do - nil -> - {:error, :not_found} - - %StepExecution{execution: execution} = step_execution -> - if Scope.can_view_workflow?(scope, execution.workflow) do - {:ok, step_execution} - else - {:error, :not_found} - end - end - end - - @doc """ - Creates a step execution. - - Returns `{:ok, step_execution}` if successful, `{:error, changeset}` otherwise. - """ - @spec create_step_execution(Scope.t() | nil, step_execution_params()) :: - {:ok, StepExecution.t()} - | {:error, Ecto.Changeset.t() | :execution_not_found | :access_denied} - def create_step_execution(scope, attrs) do - execution_id = fetch_attr(attrs, :execution_id) - - case Repo.get(Execution, execution_id) |> Repo.preload(:workflow) do - nil -> - {:error, :execution_not_found} - - execution -> - if can_manage_execution?(scope, execution.workflow) do - %StepExecution{} - |> StepExecution.changeset(attrs) - |> Repo.insert() - else - {:error, :access_denied} - end - end - end - - @doc """ - Updates a step execution status. - - Returns `{:ok, step_execution}` if successful, `{:error, changeset | :access_denied}` otherwise. - """ - @spec update_step_execution_status( - Scope.t() | nil, - StepExecution.t(), - StepExecution.status(), - keyword() - ) :: - {:ok, StepExecution.t()} | {:error, Ecto.Changeset.t() | :not_found | :access_denied} - def update_step_execution_status(scope, %StepExecution{} = step_execution, status, opts \\ []) do - case Repo.get(Execution, step_execution.execution_id) |> Repo.preload(:workflow) do - nil -> - {:error, :not_found} - - execution -> - if can_manage_execution?(scope, execution.workflow) do - updates = %{status: status} - - # Add timestamps based on status - updates = - case status do - :running -> - Map.put(updates, :started_at, DateTime.utc_now()) - - :queued -> - Map.put(updates, :queued_at, DateTime.utc_now()) - - s when s in [:completed, :failed, :skipped] -> - Map.put(updates, :completed_at, DateTime.utc_now()) - - _ -> - updates - end - - # Add output data if provided - updates = - if Keyword.has_key?(opts, :output_data) do - Map.put(updates, :output_data, Keyword.get(opts, :output_data)) - else - updates - end - - # Add error if provided - updates = - if Keyword.has_key?(opts, :error) do - Map.put(updates, :error, Keyword.get(opts, :error)) - else - updates - end - - # Add output_item_count if provided - updates = - if Keyword.has_key?(opts, :output_item_count) do - Map.put(updates, :output_item_count, Keyword.get(opts, :output_item_count)) - else - updates - end - - step_execution - |> StepExecution.changeset(updates) - |> Repo.update() - else - {:error, :access_denied} - end - end - end - - @doc """ - Creates a retry step execution. - - Returns `{:ok, step_execution}` if successful, `{:error, changeset | :access_denied}` otherwise. - """ - @spec retry_step_execution(Scope.t() | nil, StepExecution.t()) :: - {:ok, StepExecution.t()} | {:error, Ecto.Changeset.t() | :not_found | :access_denied} - def retry_step_execution(scope, %StepExecution{} = original) do - case Repo.get(Execution, original.execution_id) |> Repo.preload(:workflow) do - nil -> - {:error, :not_found} - - execution -> - if can_manage_execution?(scope, execution.workflow) do - %StepExecution{} - |> StepExecution.changeset(%{ - execution_id: original.execution_id, - step_id: original.step_id, - step_type_id: original.step_type_id, - input_data: original.input_data, - metadata: original.metadata, - attempt: original.attempt + 1, - retry_of_id: original.id - }) - |> Repo.insert() - else - {:error, :access_denied} - end - end - end - - @doc false - @spec record_step_execution_started(Ecto.UUID.t(), String.t(), String.t(), term()) :: - {:ok, StepExecution.t()} | {:error, Ecto.Changeset.t() | term()} - def record_step_execution_started(execution_id, step_id, step_type_id, input_data) do - attrs = %{ - execution_id: execution_id, - step_id: step_id, - step_type_id: step_type_id, - status: :running, - input_data: Serializer.wrap_for_db(input_data), - started_at: DateTime.utc_now() - } - - safe_repo(fn -> - %StepExecution{} - |> StepExecution.changeset(attrs) - |> Repo.insert() - end) - end - - @doc false - @spec record_step_execution_completed_by_id(Ecto.UUID.t(), term()) :: - {:ok, StepExecution.t()} | {:error, Ecto.Changeset.t() | :not_found | term()} - def record_step_execution_completed_by_id(step_execution_id, output_data, opts \\ []) do - case Repo.get(StepExecution, step_execution_id) do - nil -> - {:error, :not_found} - - %StepExecution{} = step_execution -> - updates = %{ - status: :completed, - output_data: Serializer.wrap_for_db(output_data), - output_item_count: Keyword.get(opts, :output_item_count), - completed_at: DateTime.utc_now() - } - - safe_repo(fn -> - step_execution - |> StepExecution.changeset(updates) - |> Repo.update() - end) - end - end - - @doc false - @spec record_step_execution_completed_by_step(Ecto.UUID.t(), String.t(), term(), keyword()) :: - {:ok, StepExecution.t()} | {:error, Ecto.Changeset.t() | :not_found | term()} - def record_step_execution_completed_by_step(execution_id, step_id, output_data, opts \\ []) do - now = DateTime.utc_now() |> DateTime.truncate(:microsecond) - error = Keyword.get(opts, :error) - output_item_count = Keyword.get(opts, :output_item_count) - - updates = [ - status: :completed, - output_data: Serializer.wrap_for_db(output_data), - output_item_count: output_item_count, - completed_at: now, - updated_at: now - ] - - updates = if error, do: Keyword.put(updates, :error, error), else: updates - - query = - from(se in StepExecution, - where: - se.execution_id == ^execution_id and se.step_id == ^step_id and - se.status in ^@active_step_statuses - ) - - safe_repo(fn -> - case Repo.update_all(query, [set: updates], returning: true) do - {0, _} -> - {:error, :not_found} - - {1, [step]} -> - {:ok, step} - - {n, steps} -> - Logger.warning("Updated multiple step executions (#{n}) for completion", - execution_id: execution_id, - step_id: step_id - ) - - {:ok, List.last(steps)} - end - end) - end - - @doc false - @spec record_step_execution_failed_by_step(Ecto.UUID.t(), String.t(), term()) :: - {:ok, StepExecution.t()} | {:error, Ecto.Changeset.t() | :not_found | term()} - def record_step_execution_failed_by_step(execution_id, step_id, reason) do - now = DateTime.utc_now() |> DateTime.truncate(:microsecond) - error = Execution.format_error({:step_failed, step_id, reason}) - - entry = %{ - execution_id: execution_id, - step_id: step_id, - status: :failed, - error: error, - completed_at: now - } - - safe_repo(fn -> - case persist_step_execution_entries([entry], now) do - {:ok, step_execution, _action} -> {:ok, step_execution} - {:error, failure_reason} -> {:error, failure_reason} - end - end) - end - - @doc false - @spec record_step_execution_skipped_by_step(Ecto.UUID.t(), String.t()) :: - {:ok, StepExecution.t()} | {:error, Ecto.Changeset.t() | :not_found | term()} - def record_step_execution_skipped_by_step(execution_id, step_id) do - case fetch_latest_active_step_execution(execution_id, step_id) do - nil -> - {:error, :not_found} - - %StepExecution{} = step_execution -> - updates = %{ - status: :skipped, - completed_at: DateTime.utc_now() - } - - safe_repo(fn -> - step_execution - |> StepExecution.changeset(updates) - |> Repo.update() - end) - end - end - - @doc false - @spec update_step_execution_metadata(Ecto.UUID.t(), String.t(), map()) :: - {:ok, StepExecution.t()} | {:error, term()} - - def update_step_execution_metadata(execution_id, step_id, metadata) do - case fetch_latest_active_step_execution(execution_id, step_id) do - nil -> - {:error, :not_found} - - %StepExecution{} = step_execution -> - new_metadata = Map.merge(step_execution.metadata || %{}, metadata) - - safe_repo(fn -> - step_execution - |> StepExecution.changeset(%{metadata: new_metadata}) - |> Repo.update() - end) - end - end - - @doc """ - Records multiple step executions in a single batch. - Merges step events per step and persists them with FSM-aware, commutative semantics. - """ - @spec record_step_executions_batch([map()]) :: {:ok, integer()} | {:error, term()} - def record_step_executions_batch(batches) when is_list(batches) do - now = DateTime.utc_now() |> DateTime.truncate(:microsecond) - - batches - |> Enum.map(&normalize_step_entry/1) - |> Enum.filter(&valid_step_entry?/1) - |> case do - [] -> - {:ok, 0} - - entries -> - safe_repo(fn -> - entries - |> Enum.group_by(fn entry -> - # Include item_index in grouping key to keep fan-out items separate - {entry.execution_id, entry.step_id, entry.item_index, entry.attempt || 1} - end) - |> Enum.reduce_while({:ok, 0}, fn {_key, grouped_entries}, {:ok, count} -> - case persist_step_execution_entries(grouped_entries, now) do - {:ok, _step_execution, :noop} -> - {:cont, {:ok, count}} - - {:ok, _step_execution, _action} -> - {:cont, {:ok, count + 1}} - - {:error, reason} -> - {:halt, {:error, reason}} - end - end) - end) - end - end - - defp persist_step_execution_entries(entries, now) when is_list(entries) do - [first | _] = entries - execution_id = Map.get(first, :execution_id) - step_id = Map.get(first, :step_id) - item_index = Map.get(first, :item_index) - attempt = Map.get(first, :attempt) || 1 - - existing = fetch_latest_step_execution(execution_id, step_id, item_index, attempt) - merged = merge_step_entries([existing | entries], now) - - if valid_step_entry?(merged) do - attrs = step_execution_attrs(merged) - - case existing do - nil -> - case StepExecution.changeset(%StepExecution{}, attrs) |> Repo.insert() do - {:ok, step_execution} -> {:ok, step_execution, :inserted} - {:error, changeset} -> {:error, changeset} - end - - %StepExecution{} = step_execution -> - changeset = StepExecution.changeset(step_execution, attrs) - - cond do - changeset.changes == %{} -> - {:ok, step_execution, :noop} - - changeset.valid? -> - case Repo.update(changeset) do - {:ok, updated} -> {:ok, updated, :updated} - {:error, update_error} -> {:error, update_error} - end - - transition_error?(changeset) -> - {:ok, step_execution, :noop} - - true -> - {:error, changeset} - end - end - else - {:error, :invalid_entry} - end - end - - defp merge_step_entries(entries, now) do - normalized = - entries - |> Enum.reject(&is_nil/1) - |> Enum.map(&normalize_step_entry/1) - |> Enum.filter(&valid_step_entry?/1) - - case normalized do - [] -> - %{} - - _ -> - base = List.first(normalized) - status = choose_step_status(normalized) - queued_at = min_datetime(normalized, :queued_at) - started_at = min_datetime(normalized, :started_at) - - completed_at = - normalized - |> max_datetime(:completed_at) - |> maybe_default_completed_at(status, now) - - %{ - execution_id: base.execution_id, - step_id: base.step_id, - step_type_id: pick_first_non_nil(normalized, :step_type_id), - status: status, - input_data: pick_input_data(normalized), - output_data: pick_output_data(normalized), - output_item_count: pick_output_item_count(normalized), - item_index: pick_first_non_nil(normalized, :item_index), - items_total: pick_first_non_nil(normalized, :items_total), - error: pick_error(normalized), - metadata: merge_metadata(normalized), - queued_at: queued_at, - started_at: started_at, - completed_at: completed_at, - attempt: pick_attempt(normalized), - retry_of_id: pick_first_non_nil(normalized, :retry_of_id) - } - end - end - - defp step_execution_attrs(merged) do - %{ - execution_id: merged.execution_id, - step_id: merged.step_id, - step_type_id: merged.step_type_id || "unknown", - status: merged.status || :pending, - input_data: Serializer.wrap_for_db(merged.input_data), - output_data: Serializer.wrap_for_db(merged.output_data), - output_item_count: merged.output_item_count, - item_index: merged[:item_index], - items_total: merged[:items_total], - error: merged.error, - metadata: merged.metadata || %{}, - queued_at: merged.queued_at, - started_at: merged.started_at, - completed_at: merged.completed_at, - attempt: merged.attempt || 1, - retry_of_id: merged.retry_of_id - } - end - - defp normalize_step_entry(%StepExecution{} = step_execution) do - %{ - execution_id: step_execution.execution_id, - step_id: step_execution.step_id, - step_type_id: step_execution.step_type_id, - status: step_execution.status, - input_data: step_execution.input_data, - output_data: step_execution.output_data, - output_item_count: step_execution.output_item_count, - item_index: step_execution.item_index, - items_total: step_execution.items_total, - error: step_execution.error, - metadata: step_execution.metadata, - queued_at: step_execution.queued_at, - started_at: step_execution.started_at, - completed_at: step_execution.completed_at, - attempt: step_execution.attempt, - retry_of_id: step_execution.retry_of_id - } - end - - defp normalize_step_entry(entry) when is_map(entry) do - %{ - execution_id: fetch_step_value(entry, :execution_id), - step_id: fetch_step_value(entry, :step_id), - step_type_id: fetch_step_value(entry, :step_type_id), - status: normalize_status(fetch_step_value(entry, :status)), - input_data: fetch_step_value(entry, :input_data), - output_data: fetch_step_value(entry, :output_data), - output_item_count: fetch_step_value(entry, :output_item_count), - item_index: fetch_step_value(entry, :item_index), - items_total: fetch_step_value(entry, :items_total), - error: fetch_step_value(entry, :error), - metadata: normalize_metadata(fetch_step_value(entry, :metadata)), - queued_at: normalize_datetime(fetch_step_value(entry, :queued_at)), - started_at: normalize_datetime(fetch_step_value(entry, :started_at)), - completed_at: normalize_datetime(fetch_step_value(entry, :completed_at)), - attempt: fetch_step_value(entry, :attempt), - retry_of_id: fetch_step_value(entry, :retry_of_id) - } - end - - defp normalize_step_entry(_entry), do: %{} - - defp valid_step_entry?(entry) do - execution_id = Map.get(entry, :execution_id) - step_id = Map.get(entry, :step_id) - is_binary(execution_id) and is_binary(step_id) and step_id != "" - end - - defp fetch_step_value(entry, key) do - Map.get(entry, key) || Map.get(entry, Atom.to_string(key)) - end - - defp normalize_status(status) when is_atom(status), do: status - - defp normalize_status(status) when is_binary(status) do - case status do - "pending" -> :pending - "queued" -> :queued - "running" -> :running - "completed" -> :completed - "failed" -> :failed - "skipped" -> :skipped - "cancelled" -> :cancelled - _ -> nil - end - end - - defp normalize_status(_), do: nil - - defp normalize_metadata(metadata) when is_map(metadata), do: metadata - defp normalize_metadata(_), do: %{} - - defp normalize_datetime(%DateTime{} = dt), do: dt - defp normalize_datetime(_), do: nil - - defp choose_step_status(entries) do - {terminal_entries, active_entries} = - Enum.split_with(entries, fn entry -> terminal_status?(entry.status) end) - - cond do - terminal_entries != [] -> - terminal_entries - |> Enum.max_by(&terminal_status_key/1) - |> Map.get(:status) - - active_entries != [] -> - active_entries - |> Enum.max_by(&active_status_key/1) - |> Map.get(:status) - - true -> - :pending - end - end - - defp terminal_status?(status) when status in [:completed, :failed, :skipped, :cancelled], - do: true - - defp terminal_status?(_status), do: false - - defp terminal_status_key(entry) do - { - timestamp_to_int(entry.completed_at), - Map.get(@terminal_status_priority, entry.status, 0) - } - end - - defp active_status_key(entry) do - { - Map.get(@active_status_rank, entry.status, 0), - timestamp_to_int(active_status_time(entry)) - } - end - - defp active_status_time(%{status: :running} = entry), do: entry.started_at - defp active_status_time(%{status: :queued} = entry), do: entry.queued_at - defp active_status_time(_entry), do: nil - - defp pick_first_non_nil(entries, key) do - entries - |> sort_entries_by_event_time(:asc) - |> Enum.find_value(&Map.get(&1, key)) - end - - defp pick_attempt(entries) do - entries - |> Enum.map(&Map.get(&1, :attempt)) - |> Enum.reject(&is_nil/1) - |> Enum.max(fn -> 1 end) - end - - defp pick_input_data(entries) do - entries - |> sort_entries_by_timestamp(:started_at, :asc) - |> Enum.find_value(&Map.get(&1, :input_data)) - end - - defp pick_output_data(entries) do - entries - |> Enum.filter(&terminal_status?(&1.status)) - |> sort_entries_by_timestamp(:completed_at, :desc) - |> Enum.find_value(&Map.get(&1, :output_data)) - end - - defp pick_output_item_count(entries) do - entries - |> Enum.filter(&terminal_status?(&1.status)) - |> sort_entries_by_timestamp(:completed_at, :desc) - |> Enum.find_value(&Map.get(&1, :output_item_count)) - end - - defp pick_error(entries) do - entries - |> Enum.filter(&Map.get(&1, :error)) - |> sort_entries_by_timestamp(:completed_at, :desc) - |> Enum.find_value(&Map.get(&1, :error)) - end - - defp merge_metadata(entries) do - entries - |> sort_entries_by_event_time(:asc) - |> Enum.reduce(%{}, fn entry, acc -> - Map.merge(acc, entry.metadata || %{}) - end) - end - - defp min_datetime(entries, key) do - entries - |> Enum.map(&Map.get(&1, key)) - |> Enum.reject(&is_nil/1) - |> Enum.min(fn -> nil end) - end - - defp max_datetime(entries, key) do - entries - |> Enum.map(&Map.get(&1, key)) - |> Enum.reject(&is_nil/1) - |> Enum.max(fn -> nil end) - end - - defp maybe_default_completed_at(nil, status, now) - when status in [:completed, :failed, :skipped, :cancelled], - do: now - - defp maybe_default_completed_at(completed_at, _status, _now), do: completed_at - - defp sort_entries_by_timestamp(entries, field, order) do - Enum.sort_by(entries, fn entry -> - timestamp_sort_key(Map.get(entry, field), order, entry.status) - end) - end - - defp sort_entries_by_event_time(entries, order) do - Enum.sort_by(entries, fn entry -> - timestamp_sort_key(event_time(entry), order, entry.status) - end) - end - - defp event_time(entry) do - case entry.status do - status when status in [:completed, :failed, :skipped, :cancelled] -> - entry.completed_at - - :running -> - entry.started_at - - :queued -> - entry.queued_at - - _ -> - nil - end - end - - defp timestamp_sort_key(%DateTime{} = dt, :asc, status) do - {DateTime.to_unix(dt, :microsecond), Map.get(@status_sort_rank, status, 0)} - end - - defp timestamp_sort_key(%DateTime{} = dt, :desc, status) do - {-DateTime.to_unix(dt, :microsecond), Map.get(@status_sort_rank, status, 0)} - end - - defp timestamp_sort_key(nil, :asc, status) do - {@max_timestamp, Map.get(@status_sort_rank, status, 0)} - end - - defp timestamp_sort_key(nil, :desc, status) do - {0, Map.get(@status_sort_rank, status, 0)} - end - - defp timestamp_to_int(%DateTime{} = dt), do: DateTime.to_unix(dt, :microsecond) - defp timestamp_to_int(_), do: 0 - - defp transition_error?(%Ecto.Changeset{errors: errors}) do - Enum.any?(errors, fn - {:status, {_message, opts}} -> Keyword.get(opts, :validation) == :transition - _ -> false - end) - end - - defp fetch_latest_step_execution(nil, _step_id, _item_index, _attempt), do: nil - - defp fetch_latest_step_execution(execution_id, step_id, item_index, attempt) do - case Ecto.UUID.cast(execution_id) do - {:ok, uuid} -> - query = - from(se in StepExecution, - where: - se.execution_id == ^uuid and se.step_id == ^step_id and - se.attempt == ^attempt, - order_by: [desc: se.inserted_at], - limit: 1 - ) - - # Add item_index filter - use is_nil for NULL matching - query = - if is_nil(item_index) do - from(se in query, where: is_nil(se.item_index)) - else - from(se in query, where: se.item_index == ^item_index) - end - - Repo.one(query) - - :error -> - nil - end - end - - defp fetch_latest_active_step_execution(nil, _step_id), do: nil - - defp fetch_latest_active_step_execution(execution_id, step_id) do - case Ecto.UUID.cast(execution_id) do - {:ok, uuid} -> - from(se in StepExecution, - where: - se.execution_id == ^uuid and se.step_id == ^step_id and - se.status in ^@active_step_statuses, - order_by: [desc: se.inserted_at], - limit: 1 - ) - |> Repo.one() - - :error -> - nil - end - end - - defp safe_repo(fun) when is_function(fun, 0) do - fun.() - rescue - error -> - {:error, error} - end - - @doc """ - Counts executions by status for the given scope. - - Returns a map with status counts. - """ - @spec count_executions_by_status(Scope.t() | nil) :: %{optional(atom()) => non_neg_integer()} - def count_executions_by_status(%Scope{workspace: %{id: workspace_id}} = scope) - when is_binary(workspace_id) do - case Scope.can_view_workflow?(scope, %Workflow{workspace_id: workspace_id}) do - true -> - query = - from e in Execution, - join: w in Workflow, - on: e.workflow_id == w.id, - where: w.workspace_id == ^workspace_id, - select: {e.status, count(e.id)}, - group_by: e.status - - Repo.all(query) |> Map.new() - - false -> - %{} - end - end - - def count_executions_by_status(_scope), do: %{} - - @doc """ - Gets execution statistics for the last N days. - - Returns a list of daily execution counts. - """ - @spec get_execution_stats(Scope.t() | nil, pos_integer()) :: [ - %{date: Date.t(), count: non_neg_integer()} - ] - def get_execution_stats(scope, days \\ 30) - - def get_execution_stats(%Scope{workspace: %{id: workspace_id}} = scope, days) - when is_binary(workspace_id) do - case Scope.can_view_workflow?(scope, %Workflow{workspace_id: workspace_id}) do - true -> - start_date = Date.add(Date.utc_today(), -days) - - query = - from e in Execution, - join: w in Workflow, - on: e.workflow_id == w.id, - where: w.workspace_id == ^workspace_id, - where: fragment("date(?) >= ?", e.inserted_at, ^start_date), - select: %{ - date: fragment("date(?)", e.inserted_at), - count: count(e.id) - }, - group_by: fragment("date(?)", e.inserted_at), - order_by: fragment("date(?)", e.inserted_at) - - Repo.all(query) - - false -> - [] - end - end - - def get_execution_stats(_scope, _days), do: [] -end diff --git a/lib/fizz/executions/events.ex b/lib/fizz/executions/events.ex deleted file mode 100644 index 9599e38..0000000 --- a/lib/fizz/executions/events.ex +++ /dev/null @@ -1,185 +0,0 @@ -defmodule Fizz.Executions.Events do - @moduledoc """ - Canonical execution lifecycle event contract and broadcaster. - - All runtime and execution lifecycle events should be emitted through this module. - """ - - require Logger - - alias Fizz.Executions.PubSub - alias Fizz.Serializer - - @execution_lifecycle_events [ - :execution_started, - :execution_updated, - :execution_completed, - :execution_cancelled, - :execution_failed - ] - @step_lifecycle_events [ - :step_started, - :step_completed, - :step_failed, - :step_skipped, - :step_cancelled - ] - - @type event_name :: - :execution_started - | :execution_updated - | :execution_completed - | :execution_cancelled - | :execution_failed - | :step_started - | :step_completed - | :step_failed - | :step_skipped - | :step_cancelled - - @type event :: %{ - event_name: event_name(), - execution_id: String.t(), - workflow_id: String.t() | nil, - occurred_at: DateTime.t(), - payload: map(), - meta: %{ - schema_version: pos_integer(), - source: atom() - } - } - - @doc """ - Emits a canonical execution lifecycle event. - - ## Options - - - `:workflow_id` - workflow id for workflow-level broadcasts - - `:source` - event producer identifier - """ - @spec emit(event_name(), String.t(), map(), keyword()) :: :ok - def emit(event_name, execution_id, payload \\ %{}, opts \\ []) - when is_binary(execution_id) and is_map(payload) and is_list(opts) do - event = build_event(event_name, execution_id, payload, opts) - - log_event(event) - broadcast_event(event) - emit_telemetry(event) - - :ok - end - - @spec execution_lifecycle_event?(term()) :: boolean() - def execution_lifecycle_event?(event_name), do: event_name in @execution_lifecycle_events - - @spec step_lifecycle_event?(term()) :: boolean() - def step_lifecycle_event?(event_name), do: event_name in @step_lifecycle_events - - defp build_event(event_name, execution_id, payload, opts) do - %{ - event_name: event_name, - execution_id: execution_id, - workflow_id: workflow_id(opts, payload), - occurred_at: DateTime.utc_now(), - payload: sanitize_payload(payload), - meta: %{ - schema_version: 1, - source: event_source(opts) - } - } - end - - defp event_source(opts) do - case Keyword.get(opts, :source, :runtime) do - source when is_atom(source) -> source - _ -> :runtime - end - end - - defp workflow_id(opts, payload) do - case Keyword.get(opts, :workflow_id) do - workflow_id when is_binary(workflow_id) and byte_size(workflow_id) > 0 -> - workflow_id - - _ -> - fetch_payload_value(payload, :workflow_id) - end - end - - defp broadcast_event(event) do - message = {:execution_event, event} - broadcast(PubSub.execution_topic(event.execution_id), message) - - if is_binary(event.workflow_id) do - broadcast(PubSub.workflow_executions_topic(event.workflow_id), message) - end - rescue - e -> - Logger.warning("Failed to broadcast execution event", - event_name: event.event_name, - execution_id: event.execution_id, - reason: inspect(e) - ) - end - - defp broadcast(topic, message) do - Phoenix.PubSub.broadcast(Fizz.PubSub, topic, message) - end - - defp sanitize_payload(payload) when is_map(payload) do - Serializer.sanitize(payload) - rescue - _ -> %{error: "Failed to sanitize payload"} - end - - defp log_event(%{event_name: event_name} = event) do - if event_log_level(event_name) == :error do - Logger.error(event_message(event_name), - event_name: event_name, - execution_id: event.execution_id, - workflow_id: event.workflow_id, - payload: event.payload - ) - end - end - - defp emit_telemetry(event) do - :telemetry.execute( - [:fizz, :execution, :event], - %{system_time: System.system_time()}, - %{ - event_name: event.event_name, - execution_id: event.execution_id, - workflow_id: event.workflow_id, - payload: event.payload, - meta: event.meta - } - ) - end - - defp event_log_level(:execution_failed), do: :error - defp event_log_level(:step_failed), do: :error - defp event_log_level(_event_name), do: :info - - defp event_message(:execution_started), do: "Execution started" - defp event_message(:execution_updated), do: "Execution updated" - defp event_message(:execution_completed), do: "Execution completed" - defp event_message(:execution_cancelled), do: "Execution cancelled" - defp event_message(:execution_failed), do: "Execution failed" - defp event_message(:step_started), do: "Step started" - defp event_message(:step_completed), do: "Step completed" - defp event_message(:step_failed), do: "Step failed" - defp event_message(:step_skipped), do: "Step skipped" - defp event_message(:step_cancelled), do: "Step cancelled" - defp event_message(event_name), do: "Execution event: #{inspect(event_name)}" - - defp fetch_payload_value(payload, key) when is_map(payload) do - string_key = Atom.to_string(key) - - cond do - Map.has_key?(payload, key) -> Map.get(payload, key) - Map.has_key?(payload, string_key) -> Map.get(payload, string_key) - true -> nil - end - end -end diff --git a/lib/fizz/executions/execution.ex b/lib/fizz/executions/execution.ex deleted file mode 100644 index 525c304..0000000 --- a/lib/fizz/executions/execution.ex +++ /dev/null @@ -1,280 +0,0 @@ -defmodule Fizz.Executions.Execution do - @moduledoc """ - Workflow execution instance. - - Tracks the runtime state of a single workflow execution including - status, timing, context (accumulated step outputs), and error information. - """ - @derive {Jason.Encoder, - only: [ - :id, - :workflow_id, - :status, - :execution_type, - :trigger, - :context, - :output, - :error, - :waiting_for, - :started_at, - :completed_at, - :expires_at, - :metadata, - :triggered_by_user_id, - :inserted_at, - :updated_at - ]} - @derive {LiveVue.Encoder, - only: [ - :id, - :workflow_id, - :status, - :execution_type, - :trigger, - :context, - :output, - :error, - :metadata, - :triggered_by_user_id, - :started_at, - :completed_at, - :inserted_at, - :updated_at - ]} - use Fizz.Schema - - alias Fizz.Workflows.Workflow - alias Fizz.Accounts.User - alias Fizz.Executions.StepExecution - - @type status :: :pending | :running | :paused | :completed | :failed | :cancelled | :timeout - @type trigger_type :: :manual | :schedule | :webhook | :event - @type execution_type :: :production | :preview | :partial - - @statuses [:pending, :running, :paused, :completed, :failed, :cancelled, :timeout] - @execution_types [:production, :preview, :partial] - - defmodule Trigger do - @moduledoc "Embedded trigger data for an execution" - @derive Jason.Encoder - @derive {LiveVue.Encoder, only: [:type, :data]} - use Ecto.Schema - - @type t :: %__MODULE__{ - type: Fizz.Executions.Execution.trigger_type(), - data: map() - } - - @primary_key false - embedded_schema do - field :type, Ecto.Enum, values: [:manual, :schedule, :webhook, :event] - field :data, :map, default: %{} - end - end - - defmodule Metadata do - @moduledoc "Embedded metadata for execution correlation and debugging" - use Ecto.Schema - @derive Jason.Encoder - @derive {LiveVue.Encoder, - only: [ - :trace_id, - :correlation_id, - :triggered_by, - :parent_execution_id, - :tags, - :extras - ]} - - @type t :: %__MODULE__{ - trace_id: String.t() | nil, - correlation_id: String.t() | nil, - triggered_by: String.t() | nil, - parent_execution_id: Ecto.UUID.t() | nil, - tags: map(), - extras: map() - } - - @primary_key false - embedded_schema do - field :trace_id, :string - field :correlation_id, :string - field :triggered_by, :string - field :parent_execution_id, :binary_id - field :tags, :map, default: %{} - field :extras, :map, default: %{} - end - end - - @type t :: %__MODULE__{ - id: Ecto.UUID.t(), - workflow_id: Ecto.UUID.t(), - status: status(), - execution_type: execution_type(), - trigger: Trigger.t(), - context: map(), - output: map() | nil, - error: map() | nil, - waiting_for: map() | nil, - started_at: DateTime.t() | nil, - completed_at: DateTime.t() | nil, - expires_at: DateTime.t() | nil, - metadata: Metadata.t() | nil, - triggered_by_user_id: Ecto.UUID.t() | nil, - inserted_at: DateTime.t(), - updated_at: DateTime.t() - } - - schema "executions" do - belongs_to :workflow, Workflow - - field :status, Ecto.Enum, values: @statuses, default: :pending - field :execution_type, Ecto.Enum, values: @execution_types, default: :production - - embeds_one :trigger, Trigger, on_replace: :update - embeds_one :metadata, Metadata, on_replace: :update - - # Accumulated outputs from all steps: %{"step_id" => output_data} - field :context, :map, default: %{} - - # Final declared output (from an output step) - field :output, :map - - # Error details if failed - field :error, :map - - # For waiting/paused executions (e.g., awaiting webhook callback) - field :waiting_for, :map - - # Timing - field :started_at, :utc_datetime_usec - field :completed_at, :utc_datetime_usec - field :expires_at, :utc_datetime_usec - - belongs_to :triggered_by_user, User, foreign_key: :triggered_by_user_id - has_many :step_executions, StepExecution - - timestamps() - end - - def changeset(execution, attrs) do - execution - |> cast(attrs, [ - :workflow_id, - :status, - :execution_type, - :context, - :output, - :error, - :waiting_for, - :started_at, - :completed_at, - :expires_at, - :triggered_by_user_id - ]) - |> cast_embed(:trigger, required: true, with: &trigger_changeset/2) - |> cast_embed(:metadata, with: &metadata_changeset/2) - |> validate_required([:workflow_id, :status, :execution_type]) - |> foreign_key_constraint(:workflow_id) - |> foreign_key_constraint(:triggered_by_user_id) - end - - defp trigger_changeset(trigger, attrs) do - trigger - |> cast(attrs, [:type, :data]) - |> validate_required([:type]) - end - - defp metadata_changeset(metadata, attrs) do - metadata - |> cast(attrs, [ - :trace_id, - :correlation_id, - :triggered_by, - :parent_execution_id, - :tags, - :extras - ]) - end - - # Convenience functions - - @doc "Returns the trigger type as an atom." - def trigger_type(%__MODULE__{trigger: %Trigger{type: type}}), do: type - def trigger_type(%__MODULE__{trigger: nil}), do: nil - - @doc "Returns the trigger input data." - def trigger_data(%__MODULE__{trigger: %Trigger{data: data}}), do: data - def trigger_data(%__MODULE__{trigger: nil}), do: %{} - - @doc "Checks if the execution is in a terminal state." - def terminal?(%__MODULE__{status: status}) - when status in [:completed, :failed, :cancelled, :timeout], - do: true - - def terminal?(%__MODULE__{}), do: false - - @doc "Checks if the execution is still running." - def active?(%__MODULE__{status: status}) when status in [:pending, :running, :paused], do: true - def active?(%__MODULE__{}), do: false - - @doc """ - Formats an execution error reason into a standard error map. - """ - def format_error(reason) do - case reason do - %{"type" => _} = error -> - error - - {:step_failed, step_id, step_reason} -> - %{"type" => "step_failure", "step_id" => step_id, "reason" => inspect(step_reason)} - - {:workflow_build_failed, build_reason} -> - %{"type" => "workflow_build_failed", "reason" => inspect(build_reason)} - - {:build_failed, message} -> - %{"type" => "build_failure", "message" => message} - - {:cycle_detected, step_ids} -> - %{"type" => "cycle_detected", "step_ids" => step_ids} - - {:invalid_connections, connections} -> - %{ - "type" => "invalid_connections", - "connections" => - Enum.map(connections, fn - c when is_struct(c) -> Map.from_struct(c) - c -> c - end) - } - - {:update_failed, %Ecto.Changeset{} = changeset} -> - %{"type" => "update_failed", "errors" => inspect(changeset.errors)} - - {:unexpected_error, message} -> - %{"type" => "unexpected_error", "message" => message} - - {:caught_error, kind, caught_reason} -> - %{"type" => "caught_error", "kind" => inspect(kind), "reason" => inspect(caught_reason)} - - other -> - %{"type" => "unknown", "reason" => inspect(other)} - end - end - - @doc "Computes duration in milliseconds, or nil if not yet complete." - def duration_ms(%__MODULE__{started_at: nil}), do: nil - def duration_ms(%__MODULE__{completed_at: nil}), do: nil - - def duration_ms(%__MODULE__{started_at: started, completed_at: completed}) do - DateTime.diff(completed, started, :millisecond) - end - - @doc "Computes duration in microseconds, or nil if not yet complete." - def duration_us(%__MODULE__{started_at: nil}), do: nil - def duration_us(%__MODULE__{completed_at: nil}), do: nil - - def duration_us(%__MODULE__{started_at: started, completed_at: completed}) do - DateTime.diff(completed, started, :microsecond) - end -end diff --git a/lib/fizz/executions/pub_sub.ex b/lib/fizz/executions/pub_sub.ex deleted file mode 100644 index c427a69..0000000 --- a/lib/fizz/executions/pub_sub.ex +++ /dev/null @@ -1,153 +0,0 @@ -defmodule Fizz.Executions.PubSub do - @moduledoc """ - PubSub topic and authorization helpers for execution updates. - - Canonical event payloads are emitted through `Fizz.Executions.Events`. - """ - - alias Fizz.Accounts.Scope - alias Fizz.Executions.Events - - @pubsub Fizz.PubSub - - @step_lifecycle_events [ - :step_started, - :step_completed, - :step_failed, - :step_skipped, - :step_cancelled - ] - - # Topic builders - def execution_topic(execution_id), do: "execution:#{execution_id}" - def workflow_executions_topic(workflow_id), do: "workflow_executions:#{workflow_id}" - - # ============================================================================ - # Subscriptions (Scope Required) - # ============================================================================ - - @doc """ - Subscribe to updates for a specific execution. - - Requires a scope with view access to the execution's workflow. - Returns `:ok` on success, `{:error, :unauthorized}` if access denied, - or `{:error, :not_found}` if execution doesn't exist. - """ - @spec subscribe_execution(Scope.t() | nil, String.t()) :: - :ok | {:error, :unauthorized | :not_found} - def subscribe_execution(scope, execution_id) do - case authorize_execution(scope, execution_id) do - :ok -> - Phoenix.PubSub.subscribe(@pubsub, execution_topic(execution_id)) - :ok - - error -> - error - end - end - - @doc """ - Unsubscribe from a specific execution's updates. - """ - @spec unsubscribe_execution(String.t()) :: :ok - def unsubscribe_execution(execution_id) do - Phoenix.PubSub.unsubscribe(@pubsub, execution_topic(execution_id)) - end - - @doc """ - Subscribe to all execution updates for a workflow. - - Requires a scope with view access to the workflow. - Returns `:ok` on success, `{:error, :unauthorized}` if access denied, - or `{:error, :not_found}` if workflow doesn't exist. - """ - @spec subscribe_workflow_executions(Scope.t() | nil, String.t()) :: - :ok | {:error, :unauthorized | :not_found} - def subscribe_workflow_executions(scope, workflow_id) do - case authorize_workflow(scope, workflow_id) do - :ok -> - Phoenix.PubSub.subscribe(@pubsub, workflow_executions_topic(workflow_id)) - :ok - - error -> - error - end - end - - @doc """ - Unsubscribe from a workflow's execution updates. - """ - @spec unsubscribe_workflow_executions(String.t()) :: :ok - def unsubscribe_workflow_executions(workflow_id) do - Phoenix.PubSub.unsubscribe(@pubsub, workflow_executions_topic(workflow_id)) - end - - # ============================================================================ - # Authorization - # ============================================================================ - - @doc """ - Checks if the scope can subscribe to updates for a specific execution. - - Returns `:ok` if authorized, `{:error, :not_found}` if execution doesn't exist, - or `{:error, :unauthorized}` if access denied. - """ - @spec authorize_execution(Scope.t() | nil, String.t()) :: - :ok | {:error, :unauthorized | :not_found} - def authorize_execution(scope, execution_id) do - case Fizz.Repo.get(Fizz.Executions.Execution, execution_id) do - nil -> - {:error, :not_found} - - execution -> - execution = Fizz.Repo.preload(execution, :workflow) - - if Scope.can_view_execution?(scope, execution) do - :ok - else - {:error, :unauthorized} - end - end - end - - @doc """ - Checks if the scope can subscribe to execution updates for a workflow. - - Returns `:ok` if authorized, `{:error, :not_found}` if workflow doesn't exist, - or `{:error, :unauthorized}` if access denied. - """ - @spec authorize_workflow(Scope.t() | nil, String.t()) :: - :ok | {:error, :unauthorized | :not_found} - def authorize_workflow(scope, workflow_id) do - case Fizz.Repo.get(Fizz.Workflows.Workflow, workflow_id) do - nil -> - {:error, :not_found} - - workflow -> - if Scope.can_view_workflow?(scope, workflow) do - :ok - else - {:error, :unauthorized} - end - end - end - - # ============================================================================ - # Runtime Step Broadcasts - # ============================================================================ - - @doc """ - Broadcasts a step lifecycle event using the canonical execution event envelope. - """ - @spec broadcast_step(atom(), String.t(), String.t() | nil, map()) :: :ok - def broadcast_step(event_name, execution_id, workflow_id, payload) - when event_name in @step_lifecycle_events and is_binary(execution_id) and is_map(payload) do - Events.emit( - event_name, - execution_id, - payload, - workflow_id: workflow_id, - source: :runtime_step - ) - end -end diff --git a/lib/fizz/executions/step_execution.ex b/lib/fizz/executions/step_execution.ex deleted file mode 100644 index 307fb52..0000000 --- a/lib/fizz/executions/step_execution.ex +++ /dev/null @@ -1,223 +0,0 @@ -defmodule Fizz.Executions.StepExecution do - @moduledoc """ - Tracks individual step execution within a workflow execution. - - Each time a step runs (including retries), a new StepExecution record - is created to capture input, output, timing, and any errors. - """ - @derive {Jason.Encoder, except: [:__meta__, :execution]} - @derive {LiveVue.Encoder, - only: [ - :id, - :execution_id, - :step_id, - :step_type_id, - :status, - :input_data, - :output_data, - :output_item_count, - :item_index, - :items_total, - :error, - :attempt, - :retry_of_id, - :queued_at, - :started_at, - :completed_at, - :duration_us, - :metadata, - :inserted_at, - :updated_at - ]} - use Fizz.Schema - - alias Ecto.Changeset - alias Fizz.Executions.Execution - - @type status :: :pending | :queued | :running | :completed | :failed | :skipped | :cancelled - - @statuses [:pending, :queued, :running, :completed, :failed, :skipped, :cancelled] - - @allowed_transitions %{ - pending: [:queued, :running, :completed, :failed, :skipped, :cancelled], - queued: [:running, :completed, :failed, :skipped, :cancelled], - running: [:completed, :failed, :skipped, :cancelled], - completed: [], - failed: [], - skipped: [], - cancelled: [] - } - - @type t :: %__MODULE__{ - id: Ecto.UUID.t(), - execution_id: Ecto.UUID.t(), - step_id: String.t(), - step_type_id: String.t(), - status: status(), - input_data: map() | nil, - output_data: map() | nil, - output_item_count: non_neg_integer() | nil, - item_index: non_neg_integer() | nil, - items_total: non_neg_integer() | nil, - error: map() | nil, - metadata: map(), - queued_at: DateTime.t() | nil, - started_at: DateTime.t() | nil, - completed_at: DateTime.t() | nil, - duration_us: integer() | nil, - attempt: pos_integer(), - retry_of_id: Ecto.UUID.t() | nil, - inserted_at: DateTime.t(), - updated_at: DateTime.t() - } - - schema "step_executions" do - belongs_to :execution, Execution - - # Which step in the workflow definition - field :step_id, :string - field :step_type_id, :string - - field :status, Ecto.Enum, values: @statuses, default: :pending - - # Data flowing through this step - field :input_data, :map - field :output_data, :map - field :output_item_count, :integer - field :error, :map - - # Fan-out item tracking - # NULL = single-item step (backwards compatible) - # 0, 1, 2, ... = individual item within a fan-out batch - field :item_index, :integer - # Total items in batch (set on all records in a fan-out) - field :items_total, :integer - - # Extensible metadata (retry backoff info, queue details, etc.) - field :metadata, :map, default: %{} - - # Timing - when it was queued, started executing, and finished - field :queued_at, :utc_datetime_usec - field :started_at, :utc_datetime_usec - field :completed_at, :utc_datetime_usec - field :duration_us, :integer, virtual: true - - # Retry tracking - field :attempt, :integer, default: 1 - field :retry_of_id, :binary_id - - timestamps() - end - - def changeset(step_execution, attrs) do - step_execution - |> cast(attrs, [ - :execution_id, - :step_id, - :step_type_id, - :status, - :input_data, - :output_data, - :output_item_count, - :item_index, - :items_total, - :error, - :metadata, - :queued_at, - :started_at, - :completed_at, - :attempt, - :retry_of_id - ]) - |> validate_required([:execution_id, :step_id, :step_type_id, :status]) - |> validate_number(:attempt, greater_than: 0) - |> validate_number(:item_index, greater_than_or_equal_to: 0) - |> validate_number(:items_total, greater_than_or_equal_to: 1) - |> validate_number(:output_item_count, greater_than_or_equal_to: 0) - |> foreign_key_constraint(:execution_id) - |> validate_status_transition() - end - - # Convenience functions - - @doc "Checks if the step execution is in a terminal state." - def terminal?(%__MODULE__{status: status}) - when status in [:completed, :failed, :skipped, :cancelled], - do: true - - def terminal?(%__MODULE__{}), do: false - - @doc "Checks if the step execution succeeded." - def succeeded?(%__MODULE__{status: :completed}), do: true - def succeeded?(%__MODULE__{}), do: false - - @doc "Computes execution duration in milliseconds." - def duration_ms(%__MODULE__{started_at: nil}), do: nil - def duration_ms(%__MODULE__{completed_at: nil}), do: nil - - def duration_ms(%__MODULE__{started_at: started, completed_at: completed}) do - DateTime.diff(completed, started, :millisecond) - end - - @doc "Computes execution duration in microseconds." - def duration_us(%__MODULE__{started_at: nil}), do: nil - def duration_us(%__MODULE__{completed_at: nil}), do: nil - - def duration_us(%__MODULE__{started_at: started, completed_at: completed}) do - DateTime.diff(completed, started, :microsecond) - end - - @doc "Computes queue wait time in milliseconds." - def queue_time_ms(%__MODULE__{queued_at: nil}), do: nil - def queue_time_ms(%__MODULE__{started_at: nil}), do: nil - - def queue_time_ms(%__MODULE__{queued_at: queued, started_at: started}) do - DateTime.diff(started, queued, :millisecond) - end - - @doc "Computes queue wait time in microseconds." - def queue_time_us(%__MODULE__{queued_at: nil}), do: nil - def queue_time_us(%__MODULE__{started_at: nil}), do: nil - - def queue_time_us(%__MODULE__{queued_at: queued, started_at: started}) do - DateTime.diff(started, queued, :microsecond) - end - - @doc "Returns true if this is a retry attempt." - def retry?(%__MODULE__{attempt: attempt}) when attempt > 1, do: true - def retry?(%__MODULE__{}), do: false - - @doc "Returns true if this step execution is part of a fan-out batch." - def fan_out_item?(%__MODULE__{item_index: nil}), do: false - def fan_out_item?(%__MODULE__{item_index: _}), do: true - - @doc "Returns true if this is a multi-item step (items_total > 1)." - def multi_item?(%__MODULE__{items_total: nil}), do: false - def multi_item?(%__MODULE__{items_total: n}) when n > 1, do: true - def multi_item?(%__MODULE__{}), do: false - - defp validate_status_transition(%Changeset{} = changeset) do - case Changeset.fetch_change(changeset, :status) do - {:ok, new_status} -> - old_status = changeset.data.status - - if transition_allowed?(old_status, new_status) do - changeset - else - Changeset.add_error(changeset, :status, "invalid status transition", - validation: :transition - ) - end - - :error -> - changeset - end - end - - defp transition_allowed?(nil, _new_status), do: true - defp transition_allowed?(old_status, new_status) when old_status == new_status, do: true - - defp transition_allowed?(old_status, new_status) do - new_status in Map.get(@allowed_transitions, old_status, []) - end -end diff --git a/lib/fizz/runtime/expression.ex b/lib/fizz/runtime/expression.ex index 49968a4..555ac7a 100644 --- a/lib/fizz/runtime/expression.ex +++ b/lib/fizz/runtime/expression.ex @@ -10,10 +10,10 @@ defmodule Fizz.Runtime.Expression do - `{{ json }}` or `{{ json.field }}` - Current step input data - `{{ steps["StepName"].json }}` - Output from a specific step - `{{ steps.StepName.json }}` - Alternative dot notation - - `{{ execution.id }}` - Execution metadata + - `{{ execution.id }}` - Runtime metadata - `{{ workflow.id }}` - Workflow metadata - `{{ variables.name }}` - Workflow variables - - `{{ metadata.trace_id }}` - Execution metadata + - `{{ metadata.trace_id }}` - Runtime metadata - `{{ env.VARIABLE }}` - Allowed environment variables (configurable) ## Filters @@ -32,17 +32,17 @@ defmodule Fizz.Runtime.Expression do ## Examples # Simple field access - Expression.evaluate("Hello {{ json.name }}!", execution) + Expression.evaluate("Hello {{ json.name }}!", vars) # Step output access - Expression.evaluate("Status: {{ steps.HTTP.json.status }}", execution) + Expression.evaluate("Status: {{ steps.HTTP.json.status }}", vars) # With filters - Expression.evaluate("{{ json.items | size }}", execution) - Expression.evaluate("{{ json.data | json }}", execution) + Expression.evaluate("{{ json.items | size }}", vars) + Expression.evaluate("{{ json.data | json }}", vars) # Conditionals - Expression.evaluate("{% if json.active %}Yes{% else %}No{% endif %}", execution) + Expression.evaluate("{% if json.active %}Yes{% else %}No{% endif %}", vars) ## Security @@ -52,30 +52,27 @@ defmodule Fizz.Runtime.Expression do - Strict variable access (unknown vars return nil or error) """ - alias Fizz.Runtime.Expression.{Context, Filters, Cache} - alias Fizz.Executions.Execution + alias Fizz.Runtime.Expression.{Cache, Filters} @type eval_result :: {:ok, String.t()} | {:error, term()} @type eval_opts :: [ strict_variables: boolean(), strict_filters: boolean(), timeout_ms: pos_integer(), - timeout: pos_integer(), - state_store: module() | map() + timeout: pos_integer() ] @default_opts [ strict_variables: false, strict_filters: true, - timeout_ms: 5_000, - state_store: %{} + timeout_ms: 5_000 ] # Pattern to detect if a string contains Liquid expressions @expression_pattern ~r/\{\{.*?\}\}|\{%.*?%\}/s @doc """ - Evaluates a Liquid template string with the given execution context. + Evaluates a Liquid template string with the given runtime variable map. Returns `{:ok, result}` or `{:error, reason}`. @@ -84,31 +81,14 @@ defmodule Fizz.Runtime.Expression do - `:strict_variables` - Return error for undefined variables (default: false) - `:strict_filters` - Return error for undefined filters (default: true) - `:timeout_ms` - Maximum evaluation time in ms (default: 1000) - - `:state_store` - Module or map for runtime state """ - @spec evaluate(String.t(), Execution.t(), eval_opts()) :: eval_result() @spec evaluate(term(), map(), eval_opts()) :: eval_result() def evaluate(template, context, opts \\ []) - def evaluate(template, %Execution{} = execution, opts) when is_binary(template) do - opts = Keyword.merge(@default_opts, opts) - - unless contains_expression?(template) do - {:ok, template} - else - vars = build_context(execution, opts) - do_evaluate(template, vars, opts) - end - end - def evaluate(template, vars, opts) when is_binary(template) and is_map(vars) do evaluate_with_vars(template, vars, opts) end - def evaluate(data, %Execution{} = execution, opts) when is_map(data) or is_list(data) do - evaluate_deep(data, execution, opts) - end - def evaluate(data, vars, opts) when (is_map(data) or is_list(data)) and is_map(vars) do evaluate_deep(data, vars, opts) end @@ -137,16 +117,10 @@ defmodule Fizz.Runtime.Expression do Recursively walks the structure and evaluates any string values that contain Liquid expressions. """ - @spec evaluate_deep(term(), Execution.t() | map(), eval_opts()) :: + @spec evaluate_deep(term(), map(), eval_opts()) :: {:ok, term()} | {:error, term()} def evaluate_deep(data, context, opts \\ []) - def evaluate_deep(data, %Execution{} = execution, opts) do - opts = Keyword.merge(@default_opts, opts) - vars = build_context(execution, opts) - do_evaluate_deep_with_catch(data, vars, opts) - end - def evaluate_deep(data, vars, opts) when is_map(vars) do opts = Keyword.merge(@default_opts, opts) do_evaluate_deep_with_catch(data, vars, opts) @@ -286,38 +260,6 @@ defmodule Fizz.Runtime.Expression do defp do_evaluate_deep(data, _vars, _opts), do: data - defp build_context(%Execution{} = execution, opts) do - state_store = Keyword.get(opts, :state_store) - - cond do - is_map(state_store) -> - Context.build(execution, state_store) - - is_atom(state_store) and Code.ensure_loaded?(state_store) -> - step_outputs = - if function_exported?(state_store, :outputs, 1) do - case state_store.outputs(execution) do - %{} = outputs -> outputs - _ -> %{} - end - else - %{} - end - - current_input = - if function_exported?(state_store, :current_input, 1) do - state_store.current_input(execution) - else - nil - end - - Context.build(execution, step_outputs, current_input) - - true -> - Context.build(execution) - end - end - defp get_timeout_ms(opts) do Keyword.get(opts, :timeout_ms) || Keyword.get(opts, :timeout) || 5_000 end diff --git a/lib/fizz/runtime/expression/context.ex b/lib/fizz/runtime/expression/context.ex index 87aca29..6820f10 100644 --- a/lib/fizz/runtime/expression/context.ex +++ b/lib/fizz/runtime/expression/context.ex @@ -2,12 +2,8 @@ defmodule Fizz.Runtime.Expression.Context do @moduledoc """ Builds the variable context for expression evaluation. - Transforms an `Fizz.Executions.Execution` struct combined with runtime - state from `ExecutionState` into a flat map suitable for Liquid template rendering. - - This module builds the context **on-demand** from the single source of truth: - - Static data from `Execution` (trigger, metadata, workflow info) - - Dynamic data from `ExecutionState` (step outputs, current input) + Transforms a runtime metadata map into a flat map suitable for Liquid template + rendering. ## Variable Structure @@ -27,7 +23,7 @@ defmodule Fizz.Runtime.Expression.Context do "id" => "uuid" }, "variables" => %{...workflow variables...}, - "metadata" => %{...execution metadata...}, + "metadata" => %{...runtime metadata...}, "request" => %{ "user_id" => "uuid", "request_id" => "uuid", @@ -40,8 +36,6 @@ defmodule Fizz.Runtime.Expression.Context do ``` """ - alias Fizz.Executions.Execution - # Environment variables that are safe to expose # Configure via application env: config :fizz, :allowed_env_vars, [...] @default_allowed_env_vars ~w( @@ -51,46 +45,27 @@ defmodule Fizz.Runtime.Expression.Context do ) @doc """ - Builds a variable map from an execution and runtime state. + Builds a variable map from runtime metadata and step outputs. The resulting map uses string keys for compatibility with Liquid. ## Parameters - - `execution` - The Execution struct + - `ctx` - Runtime metadata map - `step_outputs` - Map of step_id -> output data - `current_input` - The input data for the current step (optional) """ - @spec build(Execution.t(), term(), term()) :: map() - def build(%Execution{} = execution, step_outputs \\ %{}, current_input \\ nil) do - # Merge persisted context with runtime data - execution_context = execution.context || %{} - step_outputs_map = if is_map(step_outputs), do: step_outputs, else: %{} - all_outputs = Map.merge(execution_context, step_outputs_map) - current_input = current_input || Execution.trigger_data(execution) + @spec build(map(), term(), term()) :: map() + def build(ctx, step_outputs \\ %{}, current_input \\ nil) - %{ - "json" => normalize_value(current_input), - "input" => normalize_value(current_input), - "steps" => build_steps_map(all_outputs), - "execution" => build_execution_map(execution), - "workflow" => build_workflow_map(execution), - "variables" => extract_variables(execution), - "metadata" => build_metadata_map(execution), - "request" => build_request_map(execution), - "trigger" => normalize_value(current_input), - "env" => build_env_map(), - "now" => DateTime.utc_now() |> DateTime.to_iso8601(), - "today" => Date.utc_today() |> Date.to_iso8601() - } - end + def build(ctx, step_outputs, current_input) when is_map(ctx) do + input = + current_input || + read_context_field(ctx, :input) || + read_context_field(ctx, :trigger) - @doc """ - Builds a variable map from an ExecutionContext. - """ - def build_from_context(ctx) when is_map(ctx) do - input = normalize_value(read_context_field(ctx, :input)) - step_outputs = read_context_field(ctx, :step_outputs, %{}) + base_step_outputs = read_context_field(ctx, :step_outputs, %{}) + runtime_step_outputs = if is_map(step_outputs), do: step_outputs, else: %{} trigger = read_context_field(ctx, :trigger) trigger_type = read_context_field(ctx, :trigger_type) || "unknown" execution_id = read_context_field(ctx, :execution_id) @@ -98,11 +73,14 @@ defmodule Fizz.Runtime.Expression.Context do variables = read_context_field(ctx, :variables, %{}) metadata = read_context_field(ctx, :metadata, %{}) request = read_context_field(ctx, :request, %{}) + metadata = normalize_map(metadata) + request = normalize_map(request) %{ - "json" => input, - "input" => input, - "steps" => build_steps_map(step_outputs), + "json" => normalize_value(input), + "input" => normalize_value(input), + "steps" => + build_steps_map(Map.merge(normalize_map(base_step_outputs), runtime_step_outputs)), "execution" => %{ "id" => execution_id, "trigger_type" => to_string(trigger_type), @@ -112,13 +90,23 @@ defmodule Fizz.Runtime.Expression.Context do "id" => workflow_id }, "variables" => normalize_map(variables), - "metadata" => normalize_map(metadata), - "request" => normalize_map(request), + "metadata" => metadata, + "request" => request, "trigger" => normalize_value(trigger), "env" => build_env_map(), "now" => DateTime.utc_now() |> DateTime.to_iso8601(), "today" => Date.utc_today() |> Date.to_iso8601() } + |> put_execution_metadata(metadata) + end + + def build(_ctx, _step_outputs, current_input), do: build_minimal(current_input || %{}) + + @doc """ + Builds a variable map from runtime metadata. + """ + def build_from_context(ctx) when is_map(ctx) do + build(ctx) end def build_from_context(_ctx), do: build_minimal() @@ -172,54 +160,6 @@ defmodule Fizz.Runtime.Expression.Context do defp build_steps_map(_), do: %{} - defp build_execution_map(%Execution{} = execution) do - trigger_type = Execution.trigger_type(execution) - trigger_data = Execution.trigger_data(execution) - metadata = execution.metadata || %{} - - %{ - "id" => execution.id, - "trigger_type" => to_string(trigger_type || "unknown"), - "trigger_data" => normalize_value(trigger_data) - } - |> maybe_put("started_at", execution.started_at) - |> maybe_put("trace_id", get_metadata_field(metadata, :trace_id)) - |> maybe_put("correlation_id", get_metadata_field(metadata, :correlation_id)) - end - - defp build_workflow_map(%Execution{} = execution) do - %{ - "id" => execution.workflow_id - } - end - - defp build_request_map(%Execution{} = execution) do - metadata = execution.metadata || %{} - extras = get_metadata_field(metadata, :extras) || %{} - request = (extras["request"] || extras[:request] || %{}) |> normalize_value() - - # Inject user_id from top level execution if not already in request - user_id = execution.triggered_by_user_id - - if is_map(request) and user_id do - Map.put_new(request, "user_id", to_string(user_id)) - else - request - end - end - - defp build_metadata_map(%Execution{metadata: metadata}) when is_struct(metadata) do - metadata - |> Map.from_struct() - |> normalize_map() - end - - defp build_metadata_map(%Execution{metadata: metadata}) when is_map(metadata) do - normalize_map(metadata) - end - - defp build_metadata_map(_), do: %{} - defp build_env_map do allowed_vars() |> Enum.reduce(%{}, fn var, acc -> @@ -234,23 +174,21 @@ defmodule Fizz.Runtime.Expression.Context do Application.get_env(:fizz, :allowed_env_vars, @default_allowed_env_vars) end - defp extract_variables(%Execution{metadata: %Execution.Metadata{extras: extras}}) - when is_map(extras) do - Map.get(extras, "variables") || Map.get(extras, :variables) || %{} - end - - defp extract_variables(%Execution{metadata: %{} = metadata}) do - Map.get(metadata, "variables") || Map.get(metadata, :variables) || %{} - end - - defp extract_variables(_), do: %{} - defp get_metadata_field(%{} = metadata, key) when is_atom(key) do Map.get(metadata, key) || Map.get(metadata, Atom.to_string(key)) end defp get_metadata_field(_, _), do: nil + defp put_execution_metadata(%{"execution" => execution} = vars, metadata) do + execution = + execution + |> maybe_put("trace_id", get_metadata_field(metadata, :trace_id)) + |> maybe_put("correlation_id", get_metadata_field(metadata, :correlation_id)) + + put_in(vars, ["execution"], execution) + end + # ============================================================================ # Value Normalization # ============================================================================ diff --git a/lib/fizz/steps/definition.ex b/lib/fizz/steps/definition.ex index c0c6f68..042a723 100644 --- a/lib/fizz/steps/definition.ex +++ b/lib/fizz/steps/definition.ex @@ -31,7 +31,7 @@ defmodule Fizz.Steps.Definition do @behaviour Fizz.Steps.Executors.Behaviour @impl true - def execute(config, input, execution) do + def execute(config, input, context) do # ... implementation end end diff --git a/lib/fizz/steps/executors/behaviour.ex b/lib/fizz/steps/executors/behaviour.ex index d294ed8..3f92706 100644 --- a/lib/fizz/steps/executors/behaviour.ex +++ b/lib/fizz/steps/executors/behaviour.ex @@ -55,16 +55,14 @@ defmodule Fizz.Steps.Executors.Behaviour do - `{:skip, reason}` - The step was skipped (e.g., condition not met) """ - alias Fizz.Executions.Execution - @doc """ - Executes the step with the given configuration, input, and execution. + Executes the step with the given configuration, input, and runtime context. ## Parameters - `config` - The step's configuration map (from `step.config`) - `input` - The input data flowing into this step (from parent steps) - - `execution` - The current Execution record. + - `context` - The current runtime metadata map. ## Returns @@ -72,7 +70,7 @@ defmodule Fizz.Steps.Executors.Behaviour do - `{:error, reason}` - Failure with error details - `{:skip, reason}` - Step was skipped """ - @callback execute(config :: map(), input :: term(), execution :: Execution.t()) :: + @callback execute(config :: map(), input :: term(), context :: map()) :: {:ok, output :: term()} | {:error, reason :: term()} | {:skip, reason :: term()} @@ -168,10 +166,10 @@ defmodule Fizz.Steps.Executors.Behaviour do This is a convenience function that combines resolution and execution. """ - def execute(type_id, config, input, execution) do + def execute(type_id, config, input, context) do case resolve(type_id) do {:ok, module} -> - module.execute(config, input, execution) + module.execute(config, input, context) {:error, reason} -> {:error, {:executor_not_found, reason}} diff --git a/lib/fizz/workflows.ex b/lib/fizz/workflows.ex index fa749ea..defa239 100644 --- a/lib/fizz/workflows.ex +++ b/lib/fizz/workflows.ex @@ -28,7 +28,6 @@ defmodule Fizz.Workflows do alias Fizz.Repo alias Fizz.Workflows.{Workflow, WorkflowVersion, WorkflowDraft} - alias Fizz.Executions.Execution alias Fizz.Accounts.Scope @type workflow_params :: %{ @@ -452,45 +451,6 @@ defmodule Fizz.Workflows do end end - # ============================================================================ - # Execution Functions - # ============================================================================ - - @doc """ - Gets executions for a workflow, checking access permissions. - - Returns a list of executions if the user has access, empty list otherwise. - """ - @spec list_workflow_executions(Scope.t() | nil, Workflow.t()) :: [Execution.t()] - def list_workflow_executions(scope, %Workflow{} = workflow) do - if Scope.can_view_workflow?(scope, workflow) do - Repo.all( - from e in Execution, - where: e.workflow_id == ^workflow.id, - order_by: [desc: e.inserted_at], - limit: 100 - ) - else - [] - end - end - - @doc """ - Counts executions for a workflow by status. - - Returns a map with status counts. - """ - @spec count_workflow_executions(Workflow.t()) :: %{optional(atom()) => non_neg_integer()} - def count_workflow_executions(%Workflow{} = workflow) do - query = - from e in Execution, - where: e.workflow_id == ^workflow.id, - select: {e.status, count(e.id)}, - group_by: e.status - - Repo.all(query) |> Map.new() - end - # ============================================================================ # Trigger Functions # ============================================================================ @@ -558,9 +518,9 @@ defmodule Fizz.Workflows do Converts a display name into a key-safe step ID. Uses underscores (not hyphens) to create identifiers safe for use as: - - Map keys in execution context + - Expression variable namespaces (`steps..json`) - Runic component names - - Expression variable names (e.g., `steps.my_step.json`) + - Connection references ## Examples diff --git a/lib/fizz/workflows/embeds/step.ex b/lib/fizz/workflows/embeds/step.ex index 5204712..a01e55c 100644 --- a/lib/fizz/workflows/embeds/step.ex +++ b/lib/fizz/workflows/embeds/step.ex @@ -11,8 +11,7 @@ defmodule Fizz.Workflows.Embeds.Step do The `id` is used everywhere as the primary instance identifier: - Runic component names - - Execution context keys (`steps..json`) - - StepExecution records + - Expression variable keys (`steps..json`) - Connection references """ @derive Jason.Encoder diff --git a/lib/fizz_web/live/execution_live/index.ex b/lib/fizz_web/live/execution_live/index.ex deleted file mode 100644 index aa49a25..0000000 --- a/lib/fizz_web/live/execution_live/index.ex +++ /dev/null @@ -1,6 +0,0 @@ -defmodule FizzWeb.ExecutionLive.Index do - @moduledoc """ - LiveView for showing a list of executions. - """ - use FizzWeb, :live_view -end diff --git a/lib/fizz_web/live/execution_live/show.ex b/lib/fizz_web/live/execution_live/show.ex deleted file mode 100644 index 6fda2e5..0000000 --- a/lib/fizz_web/live/execution_live/show.ex +++ /dev/null @@ -1,561 +0,0 @@ -defmodule FizzWeb.ExecutionLive.Show do - @moduledoc """ - LiveView for showing an execution. - """ - use FizzWeb, :live_view - - alias Fizz.Accounts - alias Fizz.Executions - alias Fizz.Executions.{Execution, StepExecution} - alias Fizz.Executions.PubSub, as: ExecutionPubSub - alias FizzWeb.ExecutionLive.ShowPresenter - alias FizzWeb.WorkflowLive.Paths - import FizzWeb.Formatters - - @execution_lifecycle_events [ - :execution_started, - :execution_updated, - :execution_completed, - :execution_cancelled, - :execution_failed - ] - @step_lifecycle_events [ - :step_started, - :step_completed, - :step_failed, - :step_skipped, - :step_cancelled - ] - - @impl true - def mount( - %{ - "workspace_id" => workspace_id, - "workflow_id" => workflow_id, - "execution_id" => execution_id - }, - _session, - socket - ) do - case Accounts.build_scope_for_workspace(socket.assigns.current_scope, workspace_id) do - {:ok, scope} -> - case Executions.get_execution_with_steps(scope, execution_id) do - {:ok, execution} -> - if execution.workflow_id == workflow_id do - step_executions = sort_step_executions(execution.step_executions) - item_stats = build_item_stats(step_executions) - - socket = - socket - |> assign(:current_scope, scope) - |> assign(:page_title, "Execution #{short_id(execution.id)}") - |> assign(:workflow, execution.workflow) - |> assign(:execution, execution) - |> assign(:execution_id, execution.id) - |> assign(:step_executions_count, length(step_executions)) - |> assign(:item_stats_by_step_id, item_stats.by_step_id) - |> assign(:item_stats_summary, item_stats.summary) - |> assign(:step_executions_data, step_executions) - |> assign_raw_execution_data(execution, step_executions) - |> stream(:step_executions, step_executions, reset: true) - - socket = - if connected?(socket) do - _ = ExecutionPubSub.subscribe_execution(scope, execution.id) - socket - else - socket - end - - {:ok, socket} - else - {:ok, redirect_to_workflows(socket, "Execution not found")} - end - - {:error, :not_found} -> - {:ok, redirect_to_workflows(socket, "Execution not found")} - end - - {:error, _reason} -> - {:ok, - socket - |> put_flash(:error, "Workspace not found") - |> redirect(to: ~p"/workspaces")} - end - end - - @impl true - def terminate(_reason, socket) do - execution_id = Map.get(socket.assigns, :execution_id) - - if execution_id do - ExecutionPubSub.unsubscribe_execution(execution_id) - end - - :ok - end - - @impl true - def handle_info( - {:execution_event, %{event_name: event_name, execution_id: execution_id}}, - socket - ) - when event_name in @execution_lifecycle_events do - if execution_id == socket.assigns.execution_id do - {:noreply, refresh_execution(socket)} - else - {:noreply, socket} - end - end - - @impl true - def handle_info( - {:execution_event, %{event_name: event_name, execution_id: execution_id}}, - socket - ) - when event_name in @step_lifecycle_events do - if execution_id == socket.assigns.execution_id do - {:noreply, refresh_step_executions(socket)} - else - {:noreply, socket} - end - end - - @impl true - def render(assigns) do - ~H""" - - <:page_header> -
-
-
-
- <.link - id="execution-back-link" - navigate={Paths.workflow_show_path(@current_scope, @workflow.id)} - class="inline-flex items-center gap-2 rounded-full border border-base-300 bg-base-100 px-4 py-2 text-xs font-semibold text-base-content/80 transition hover:border-base-300 hover:text-base-content" - > - <.icon name="hero-arrow-left" class="size-4" /> - Back to workflow - -
- -
-

- Execution {short_id(@execution.id)} -

- - {humanize(@execution.status)} - - - {execution_type_label(@execution.execution_type)} - -
- -

- Execution triggered via {execution_trigger_label(@execution)} with{" "} - {@step_executions_count} step - <%= if @step_executions_count == 1 do %> - execution - <% else %> - executions - <% end %>. -

- -
-
- <.icon name="hero-clock" class="size-4" /> - Started {format_relative_time(@execution.started_at)} -
-
- Item runs {@item_stats_summary.total_item_runs} -
-
0} - class="flex items-center gap-2 rounded-full border border-base-200 bg-base-100 px-3 py-1 text-[11px] font-semibold text-base-content/70" - > - Multi-item steps {@item_stats_summary.multi_item_steps} -
-
- {@execution.id} -
-
-
- -
- <.link - id="execution-workflow-link" - navigate={Paths.workflow_show_path(@current_scope, @workflow.id)} - class="inline-flex items-center gap-2 rounded-full border border-base-300 bg-base-100 px-4 py-2 text-xs font-semibold text-base-content/80 transition hover:border-base-300 hover:text-base-content" - > - <.icon name="hero-squares-2x2" class="size-4" /> - Workflow details - -
- <.icon name="hero-exclamation-triangle" class="size-4" /> - Runtime unavailable -
-
-
-
- - -
-
-
-
- -
-
-

- Overview -

-
-
- Workflow - {@workflow.name} -
-
- Triggered by - - {triggered_by_label(@execution)} - -
-
- Execution type - - {execution_type_label(@execution.execution_type)} - -
-
-
- -
-

- Timing -

-
-
- Started - - {formatted_timestamp(@execution.started_at)} - -
-
- Completed - - {formatted_timestamp(@execution.completed_at)} - -
-
- Duration - - {format_duration(Execution.duration_us(@execution))} - -
-
-
- -
-

- Trace -

-
-
- Trace ID - - {trace_value(@execution)} - -
-
- Correlation - - {correlation_value(@execution)} - -
-
- Parent Execution - - {parent_execution_value(@execution)} - -
-
-
-
-
-
- -
-
-
-

- <.icon name="hero-bolt" class="size-5 text-primary" /> Trigger Input -

-

- Trigger payload used to start the run. -

-
{format_payload(trigger_payload(@execution))}
-
- -
-

- <.icon name="hero-squares-2x2" class="size-5 text-primary" /> Context Snapshot -

-

- Aggregated outputs from all steps so far. -

-
{format_payload(@execution.context)}
-
- -
-

- <.icon name="hero-arrow-up-tray" class="size-5 text-primary" /> Output -

-

- Final output from the workflow. -

-
{format_payload(@execution.output)}
-
- -
-

- <.icon name="hero-clipboard-document-list" class="size-5 text-primary" /> Metadata -

-

- Debug metadata attached to the execution. -

-
{format_payload(@execution.metadata)}
-
-
-
- -
-
-

- <.icon name="hero-exclamation-triangle" class="size-5" /> Failure Details -

-

- The run reported a failure. Inspect the error payload below. -

-
{format_payload(@execution.error)}
-
-
- -
-
-
-

- <.icon name="hero-queue-list" class="size-5 text-primary" /> Step Executions -

-

- Live status of each step in the execution. -

-
-
- {@step_executions_count} steps -
-
- -
- - -
-
-
-
- - {step.step_id} - - - Retry {step.attempt} - -
-
- {step.step_type_id || "Unknown step type"} -
-
- - {humanize(step.status)} - - - Item {step.item_index + 1} - <%= if step.items_total do %> - /{step.items_total} - <% end %> - - 1 - } - class="rounded-full border border-base-200 bg-base-100 px-2 py-1 text-[10px] font-semibold text-base-content/70" - > - Items {@item_stats_by_step_id[step.step_id].completed + - @item_stats_by_step_id[step.step_id].failed}/{@item_stats_by_step_id[ - step.step_id - ].items_total} - - - Duration {format_duration(StepExecution.duration_us(step))} - - - Queue {format_duration(StepExecution.queue_time_us(step))} - -
-
- -
-
-
- - Input: {payload_preview(step.input_data)} - -
{format_payload(step.input_data)}
-
-
- - Output: {payload_preview(step.output_data)} - -
{format_payload(step.output_data)}
-
-
- -
- - Error: {payload_preview(step.error)} - -
{format_payload(step.error)}
-
-
-
-
-
-
- -
-
-
-
-

- <.icon name="hero-code-bracket-square" class="size-5 text-primary" /> - Raw Execution Data -

-

- Full execution payload with workflow, steps, trigger, and pinned outputs. -

-
- - JSON - -
- -
- <.input - type="textarea" - id="execution-raw-json" - name="execution-raw-json" - value={@raw_execution_json} - readonly - rows="18" - class="w-full min-h-[320px] rounded-2xl border border-base-300 bg-base-100 px-4 py-3 font-mono text-[11px] leading-relaxed text-base-content/80 shadow-sm focus:border-primary focus:outline-none" - /> -
-
-
-
-
- """ - end - - defp refresh_execution(socket) do - case Executions.get_execution(socket.assigns.current_scope, socket.assigns.execution_id) do - {:ok, execution} -> - step_executions = socket.assigns.step_executions_data || [] - - socket - |> assign(:execution, execution) - |> assign_raw_execution_data(execution, step_executions) - - {:error, _} -> - socket - end - end - - defp refresh_step_executions(socket) do - step_executions = - Executions.list_step_executions(socket.assigns.current_scope, socket.assigns.execution) - - item_stats = build_item_stats(step_executions) - - socket - |> assign(:step_executions_count, length(step_executions)) - |> assign(:item_stats_by_step_id, item_stats.by_step_id) - |> assign(:item_stats_summary, item_stats.summary) - |> assign(:step_executions_data, step_executions) - |> assign_raw_execution_data(socket.assigns.execution, step_executions) - |> stream(:step_executions, sort_step_executions(step_executions), reset: true) - end - - defp redirect_to_workflows(socket, message) do - socket - |> put_flash(:error, message) - |> redirect(to: Paths.workflows_index_path(socket.assigns.current_scope)) - end - - defp execution_trigger_label(execution), do: ShowPresenter.execution_trigger_label(execution) - defp trigger_payload(execution), do: ShowPresenter.trigger_payload(execution) - defp trace_value(execution), do: ShowPresenter.trace_value(execution) - defp correlation_value(execution), do: ShowPresenter.correlation_value(execution) - defp parent_execution_value(execution), do: ShowPresenter.parent_execution_value(execution) - defp triggered_by_label(execution), do: ShowPresenter.triggered_by_label(execution) - defp execution_type_label(type), do: ShowPresenter.execution_type_label(type) - defp execution_type_class(type), do: ShowPresenter.execution_type_class(type) - defp status_pill_class(status), do: ShowPresenter.status_pill_class(status) - defp humanize(value), do: ShowPresenter.humanize(value) - - defp sort_step_executions(step_executions), - do: ShowPresenter.sort_step_executions(step_executions) - - defp payload_preview(payload), do: ShowPresenter.payload_preview(payload) - defp format_payload(payload), do: ShowPresenter.format_payload(payload) - - defp assign_raw_execution_data(socket, %Execution{} = execution, step_executions) do - raw_json = - ShowPresenter.raw_execution_json(socket.assigns.workflow, execution, step_executions) - - assign(socket, :raw_execution_json, raw_json) - end - - defp build_item_stats(step_executions), do: ShowPresenter.build_item_stats(step_executions) -end diff --git a/lib/fizz_web/live/execution_live/show_presenter.ex b/lib/fizz_web/live/execution_live/show_presenter.ex deleted file mode 100644 index 9d353f6..0000000 --- a/lib/fizz_web/live/execution_live/show_presenter.ex +++ /dev/null @@ -1,310 +0,0 @@ -defmodule FizzWeb.ExecutionLive.ShowPresenter do - @moduledoc false - - alias Fizz.Accounts.User - alias Fizz.Executions.Execution - - def execution_trigger_label(%Execution{} = execution) do - execution - |> trigger_type() - |> humanize() - end - - def trigger_payload(%Execution{trigger: %Execution.Trigger{data: data}}), do: data - def trigger_payload(_), do: nil - - def trace_value(%Execution{metadata: %Execution.Metadata{trace_id: trace_id}}), - do: trace_id || "-" - - def trace_value(_), do: "-" - - def correlation_value(%Execution{ - metadata: %Execution.Metadata{correlation_id: correlation_id} - }), - do: correlation_id || "-" - - def correlation_value(_), do: "-" - - def parent_execution_value(%Execution{ - metadata: %Execution.Metadata{parent_execution_id: parent_execution_id} - }), - do: parent_execution_id || "-" - - def parent_execution_value(_), do: "-" - - def triggered_by_label(%Execution{triggered_by_user: %User{email: email}}), do: email - def triggered_by_label(%Execution{triggered_by_user_id: nil}), do: "System" - def triggered_by_label(_), do: "Unknown" - - def execution_type_label(nil), do: "-" - def execution_type_label(type), do: humanize(type) - - def execution_type_class(:production), - do: - "bg-emerald-500/10 text-emerald-700 ring-emerald-500/30 dark:bg-emerald-500/20 dark:text-emerald-300" - - def execution_type_class(:preview), - do: "bg-sky-500/10 text-sky-700 ring-sky-500/30 dark:bg-sky-500/20 dark:text-sky-300" - - def execution_type_class(:partial), - do: - "bg-amber-500/10 text-amber-700 ring-amber-500/30 dark:bg-amber-500/20 dark:text-amber-300" - - def execution_type_class(_), do: "bg-base-200/60 text-base-content/70 ring-base-200" - - def status_pill_class(:completed), - do: - "bg-emerald-500/10 text-emerald-700 ring-emerald-500/30 dark:bg-emerald-500/20 dark:text-emerald-300" - - def status_pill_class(:failed), - do: "bg-rose-500/10 text-rose-700 ring-rose-500/30 dark:bg-rose-500/20 dark:text-rose-300" - - def status_pill_class(:running), - do: "bg-sky-500/10 text-sky-700 ring-sky-500/30 dark:bg-sky-500/20 dark:text-sky-300" - - def status_pill_class(:pending), - do: - "bg-amber-500/10 text-amber-700 ring-amber-500/30 dark:bg-amber-500/20 dark:text-amber-300" - - def status_pill_class(:paused), - do: - "bg-amber-500/10 text-amber-700 ring-amber-500/30 dark:bg-amber-500/20 dark:text-amber-300" - - def status_pill_class(:cancelled), do: "bg-base-200/60 text-base-content/70 ring-base-200" - - def status_pill_class(:timeout), - do: "bg-rose-500/10 text-rose-700 ring-rose-500/30 dark:bg-rose-500/20 dark:text-rose-300" - - def status_pill_class(:queued), - do: - "bg-violet-500/10 text-violet-700 ring-violet-500/30 dark:bg-violet-500/20 dark:text-violet-300" - - def status_pill_class(:skipped), do: "bg-base-200/60 text-base-content/70 ring-base-200" - def status_pill_class(_), do: "bg-base-200/60 text-base-content/70 ring-base-200" - - def humanize(nil), do: "-" - - def humanize(value) when is_atom(value) do - value - |> Atom.to_string() - |> String.replace("_", " ") - |> String.capitalize() - end - - def humanize(value), do: to_string(value) - - def sort_step_executions(step_executions) do - Enum.sort_by(step_executions, fn step -> - case step.inserted_at || step.started_at do - nil -> 0 - datetime -> DateTime.to_unix(datetime, :microsecond) - end - end) - end - - def fetch_payload_value(payload, key) when is_map(payload) do - Map.get(payload, key) || Map.get(payload, Atom.to_string(key)) - end - - def fetch_payload_value(_payload, _key), do: nil - - def payload_preview(nil), do: "-" - - def payload_preview(payload) do - payload - |> inspect(limit: 6, printable_limit: 200, pretty: true) - |> String.replace(~r/\s+/, " ") - |> truncate(90) - end - - def format_payload(nil), do: "-" - - def format_payload(payload) do - case Jason.encode(payload, pretty: true) do - {:ok, json} -> json - {:error, _} -> inspect(payload, pretty: true, limit: :infinity) - end - end - - def build_item_stats(step_executions) do - by_step_id = - step_executions - |> Enum.group_by(& &1.step_id) - |> Enum.into(%{}, fn {step_id, executions} -> - items_total = - executions - |> Enum.find_value(fn se -> se.items_total end) || - if(length(executions) > 1, do: length(executions), else: 1) - - completed = Enum.count(executions, &(&1.status == :completed)) - failed = Enum.count(executions, &(&1.status == :failed)) - running = Enum.count(executions, &(&1.status == :running)) - skipped = Enum.count(executions, &(&1.status == :skipped)) - - {step_id, - %{ - items_total: items_total, - completed: completed, - failed: failed, - running: running, - skipped: skipped, - count: length(executions) - }} - end) - - summary = %{ - total_item_runs: length(step_executions), - multi_item_steps: Enum.count(by_step_id, fn {_id, stats} -> stats.items_total > 1 end) - } - - %{by_step_id: by_step_id, summary: summary} - end - - def raw_execution_json(workflow, %Execution{} = execution, step_executions) do - raw_payload = %{ - workflow: workflow_raw(workflow), - execution: execution_raw(execution), - trigger: execution.trigger, - context: execution.context, - output: execution.output, - error: execution.error, - metadata: execution.metadata, - pinned: pinned_data(execution), - step_executions: Enum.map(step_executions, &step_execution_raw/1) - } - - format_payload(raw_payload) - end - - defp trigger_type(%Execution{trigger: %Execution.Trigger{type: type}}), do: type - defp trigger_type(_), do: nil - - defp truncate(value, max) when is_binary(value) and byte_size(value) > max do - String.slice(value, 0, max) <> "..." - end - - defp truncate(value, _max), do: value - - defp workflow_raw(nil), do: nil - - defp workflow_raw(workflow) do - base_workflow = - Map.take(workflow, [ - :id, - :name, - :description, - :status, - :public, - :current_version_tag, - :published_version_id, - :user_id, - :inserted_at, - :updated_at - ]) - - definition = - case workflow.published_version do - %{} = published_version -> - %{ - version_tag: published_version.version_tag, - steps: published_version.steps, - connections: published_version.connections, - source_hash: published_version.source_hash, - published_at: published_version.published_at, - source: "published" - } - - nil -> - case workflow.draft do - %{} = draft -> - %{ - version_tag: nil, - steps: draft.steps, - connections: draft.connections, - source_hash: nil, - published_at: nil, - source: "draft" - } - - _ -> - nil - end - end - - Map.put(base_workflow, :definition, definition) - end - - defp execution_raw(%Execution{} = execution) do - Map.take(execution, [ - :id, - :workflow_id, - :status, - :execution_type, - :trigger, - :context, - :output, - :error, - :waiting_for, - :started_at, - :completed_at, - :expires_at, - :metadata, - :triggered_by_user_id, - :inserted_at, - :updated_at - ]) - end - - defp step_execution_raw(step_execution) do - Map.take(step_execution, [ - :id, - :execution_id, - :step_id, - :step_type_id, - :status, - :input_data, - :output_data, - :output_item_count, - :item_index, - :items_total, - :error, - :attempt, - :retry_of_id, - :queued_at, - :started_at, - :completed_at, - :metadata, - :inserted_at, - :updated_at - ]) - end - - defp pinned_data(%Execution{} = execution) do - extras = execution.metadata && execution.metadata.extras - - pinned_steps = - case extras do - %{} -> Map.get(extras, :pinned_steps) || Map.get(extras, "pinned_steps") || [] - _ -> [] - end - - pinned_outputs = - if is_list(pinned_steps) and is_map(execution.context) do - Map.take(execution.context, pinned_steps) - else - %{} - end - - %{ - pinned_steps: pinned_steps, - pinned_outputs: pinned_outputs, - disabled_steps: fetch_metadata_list(extras, :disabled_steps) - } - end - - defp fetch_metadata_list(extras, key) when is_map(extras) do - Map.get(extras, key) || Map.get(extras, Atom.to_string(key)) || [] - end - - defp fetch_metadata_list(_extras, _key), do: [] -end diff --git a/lib/fizz_web/live/workflow_live/paths.ex b/lib/fizz_web/live/workflow_live/paths.ex index fc992fa..abb1c13 100644 --- a/lib/fizz_web/live/workflow_live/paths.ex +++ b/lib/fizz_web/live/workflow_live/paths.ex @@ -7,7 +7,4 @@ defmodule FizzWeb.WorkflowLive.Paths do def workflow_show_path(%{workspace: %{id: workspace_id}}, workflow_id), do: ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}" - - def execution_show_path(%{workspace: %{id: workspace_id}}, workflow_id, execution_id), - do: ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/execution/#{execution_id}" end diff --git a/lib/fizz_web/live/workflow_live/show.ex b/lib/fizz_web/live/workflow_live/show.ex index 86ebd43..d0a05ee 100644 --- a/lib/fizz_web/live/workflow_live/show.ex +++ b/lib/fizz_web/live/workflow_live/show.ex @@ -6,8 +6,6 @@ defmodule FizzWeb.WorkflowLive.Show do alias Fizz.Accounts alias Fizz.Workflows - alias Fizz.Executions - alias Fizz.Executions.Execution alias FizzWeb.WorkflowLive.Paths import FizzWeb.Formatters @@ -16,14 +14,11 @@ defmodule FizzWeb.WorkflowLive.Show do with {:ok, scope} <- Accounts.build_scope_for_workspace(socket.assigns.current_scope, workspace_id), {:ok, workflow} <- Workflows.get_workflow(scope, id) do - executions = Executions.list_workflow_executions(scope, workflow, limit: 10) - socket = socket |> assign(:current_scope, scope) |> assign(:page_title, workflow.name) |> assign(:workflow, workflow) - |> assign(:executions, executions) {:ok, socket} else @@ -87,86 +82,6 @@ defmodule FizzWeb.WorkflowLive.Show do
- <%!-- Recent Executions Section --%> -
-
-

- <.icon name="hero-clock" class="size-5" /> Recent Executions -

- - <%= if Enum.empty?(@executions) do %> -
- <.icon name="hero-inbox" class="size-8 mx-auto mb-2" /> -

No executions yet

-

Run the workflow to see results here

-
- <% else %> -
- - - - - - - - - - - - - - <%= for execution <- @executions do %> - - - - - - - - - - <% end %> - -
IDStatusInputOutputDurationStartedActions
-
- {short_id(execution.id)} - - <.icon name="hero-beaker" class="size-4 text-primary/80" /> - -
-
- - {execution.status} - - - {format_execution_value(execution.trigger && execution.trigger.data)} - - {format_execution_value(execution.output)} - - {format_duration(Execution.duration_us(execution))} - - {format_relative_time(execution.started_at)} - - <.link - navigate={ - Paths.execution_show_path(@current_scope, @workflow.id, execution.id) - } - class="btn btn-ghost btn-xs" - title="Inspect execution" - > - <.icon name="hero-eye" class="size-4" /> - -
-
- <% end %> -
-
- - <%!-- Workflow Details Section --%>

@@ -233,19 +148,4 @@ defmodule FizzWeb.WorkflowLive.Show do """ end - - # Helper functions - - defp partial_execution?(%{metadata: %{extras: extras}}) when is_map(extras) do - Map.get(extras, "partial") || Map.get(extras, :partial) || false - end - - defp partial_execution?(_), do: false - - defp format_execution_value(nil), do: "-" - - defp format_execution_value(%{"value" => value}), do: inspect(value) - defp format_execution_value(%{"productions" => prods}) when is_list(prods), do: inspect(prods) - defp format_execution_value(value) when is_map(value), do: inspect(value) - defp format_execution_value(value), do: inspect(value) end diff --git a/lib/fizz_web/router.ex b/lib/fizz_web/router.ex index aeb2a7b..3948901 100644 --- a/lib/fizz_web/router.ex +++ b/lib/fizz_web/router.ex @@ -80,10 +80,6 @@ defmodule FizzWeb.Router do live "/workspaces/:workspace_id/workflows", WorkflowLive.Index, :index live "/workspaces/:workspace_id/workflows/:id", WorkflowLive.Show, :show - - live "/workspaces/:workspace_id/workflows/:workflow_id/execution/:execution_id", - ExecutionLive.Show, - :show end end end diff --git a/test/fizz/accounts/scope_test.exs b/test/fizz/accounts/scope_test.exs index 6b26053..b7e9fda 100644 --- a/test/fizz/accounts/scope_test.exs +++ b/test/fizz/accounts/scope_test.exs @@ -2,7 +2,6 @@ defmodule Fizz.Accounts.ScopeTest do use ExUnit.Case, async: true alias Fizz.Accounts.Workspace - alias Fizz.Executions.Execution alias Fizz.Accounts.Scope alias Fizz.Workflows.Workflow @@ -73,20 +72,6 @@ defmodule Fizz.Accounts.ScopeTest do assert Scope.can_view_workflow?(scope, workflow) end - test "can_view_execution?/2 delegates to workflow access" do - scope = scope_for_workspace("workspace_123", :viewer) - execution = %Execution{workflow: %Workflow{workspace_id: "workspace_123"}} - - assert Scope.can_view_execution?(scope, execution) - end - - test "can_create_execution?/3 allows nil scope only for production execution in scoped workflows" do - scoped_workflow = %Workflow{workspace_id: "workspace_123"} - - assert Scope.can_create_execution?(nil, scoped_workflow, :production) - refute Scope.can_create_execution?(nil, scoped_workflow, :preview) - end - defp scope_for_workspace(workspace_id, workspace_role) do %Scope{} |> Scope.with_workspace(%Workspace{ diff --git a/test/fizz/executions/cancel_active_step_executions_test.exs b/test/fizz/executions/cancel_active_step_executions_test.exs deleted file mode 100644 index 06aef56..0000000 --- a/test/fizz/executions/cancel_active_step_executions_test.exs +++ /dev/null @@ -1,128 +0,0 @@ -defmodule Fizz.Executions.CancelActiveStepExecutionsTest do - use Fizz.DataCase, async: false - - alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership} - alias Fizz.Executions - alias Fizz.Repo - alias Fizz.Workflows - - setup do - user = Fizz.AccountsFixtures.user_fixture() - workspace = workspace_fixture!(user) - scope = scoped_workspace_access(user, workspace) - workflow = workflow_fixture!(scope) - execution = execution_fixture!(scope, workflow) - - %{scope: scope, execution: execution} - end - - test "broadcasts cancelled step events with per-item identity", %{ - scope: scope, - execution: execution - } do - _step_zero = - step_execution_fixture!(scope, execution, %{ - step_id: "fan_out_step", - item_index: 0, - items_total: 2 - }) - - _step_one = - step_execution_fixture!(scope, execution, %{ - step_id: "fan_out_step", - item_index: 1, - items_total: 2 - }) - - :ok = - Phoenix.PubSub.subscribe(Fizz.PubSub, Fizz.Executions.PubSub.execution_topic(execution.id)) - - assert {2, nil} = Executions.cancel_active_step_executions(execution.id) - - payloads = - Enum.map(1..2, fn _ -> - assert_receive {:execution_event, %{event_name: :step_cancelled, payload: payload}}, 1_000 - payload - end) - - assert Enum.sort(Enum.map(payloads, & &1["item_index"])) == [0, 1] - assert Enum.all?(payloads, &(&1["status"] == "cancelled")) - assert Enum.all?(payloads, &(&1["attempt"] == 1)) - assert Enum.all?(payloads, &(&1["step_id"] == "fan_out_step")) - end - - defp workspace_fixture!(user) do - unique = System.unique_integer([:positive]) - - workspace = - %Workspace{} - |> Workspace.changeset(%{ - name: "Execution Cancel Workspace #{unique}", - slug: "execution-cancel-workspace-#{unique}", - workos_organization_id: "org_#{unique}" - }) - |> Repo.insert!() - - %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id} - |> WorkspaceMembership.changeset(%{role: :admin}) - |> Repo.insert!() - - workspace - end - - defp scoped_workspace_access(user, workspace) do - Scope.for_user(user) - |> Scope.with_organization_id(workspace.workos_organization_id) - |> Scope.with_organization_role(:owner) - |> Scope.with_workspace(workspace) - |> Scope.with_workspace_role(:admin) - end - - defp workflow_fixture!(scope) do - {:ok, workflow} = - Workflows.create_workflow(scope, %{ - name: "Cancel Broadcast Workflow #{System.unique_integer([:positive])}", - description: "cancel broadcast regression" - }) - - workflow - end - - defp execution_fixture!(scope, workflow) do - {:ok, execution} = - Executions.create_execution(scope, %{ - workflow_id: workflow.id, - status: :running, - execution_type: :preview, - started_at: DateTime.utc_now() |> DateTime.truncate(:microsecond), - trigger: %{ - type: :manual, - data: %{"source" => "test"} - } - }) - - execution - end - - defp step_execution_fixture!(scope, execution, attrs) do - now = DateTime.utc_now() |> DateTime.truncate(:microsecond) - - {:ok, step_execution} = - Executions.create_step_execution( - scope, - Map.merge( - %{ - execution_id: execution.id, - step_id: "step_a", - step_type_id: "math", - status: :running, - attempt: 1, - started_at: now - }, - attrs - ) - ) - - step_execution - end -end diff --git a/test/fizz/executions_test.exs b/test/fizz/executions_test.exs deleted file mode 100644 index 8a018aa..0000000 --- a/test/fizz/executions_test.exs +++ /dev/null @@ -1,62 +0,0 @@ -defmodule Fizz.ExecutionsTest do - use ExUnit.Case, async: true - - import Ecto.Changeset - - alias Fizz.Executions.Execution - alias Fizz.Executions.StepExecution - - describe "changesets" do - test "execution changeset requires trigger" do - changeset = - Execution.changeset(%Execution{}, %{ - workflow_id: Ecto.UUID.generate(), - status: :pending, - execution_type: :preview - }) - - refute changeset.valid? - assert %{trigger: ["can't be blank"]} = errors_on(changeset) - end - - test "step execution changeset allows output_item_count = 0" do - attrs = %{ - execution_id: Ecto.UUID.generate(), - step_id: "step_1", - step_type_id: "manual_input", - status: :completed, - output_item_count: 0 - } - - changeset = StepExecution.changeset(%StepExecution{}, attrs) - - assert changeset.valid? - assert get_change(changeset, :output_item_count) == 0 - end - - test "step execution changeset rejects negative counters" do - attrs = %{ - execution_id: Ecto.UUID.generate(), - step_id: "step_1", - step_type_id: "manual_input", - status: :pending, - output_item_count: -1, - item_index: -1, - items_total: 0 - } - - changeset = StepExecution.changeset(%StepExecution{}, attrs) - - refute changeset.valid? - - assert "must be greater than or equal to %{number}" in errors_on(changeset).output_item_count - - assert "must be greater than or equal to %{number}" in errors_on(changeset).item_index - assert "must be greater than or equal to %{number}" in errors_on(changeset).items_total - end - end - - defp errors_on(changeset) do - traverse_errors(changeset, fn {message, _opts} -> message end) - end -end diff --git a/test/fizz_web/live/workflow_live_test.exs b/test/fizz_web/live/workflow_live_test.exs index 94e1865..3b495e5 100644 --- a/test/fizz_web/live/workflow_live_test.exs +++ b/test/fizz_web/live/workflow_live_test.exs @@ -17,16 +17,4 @@ defmodule FizzWeb.WorkflowLiveTest do assert {:error, {:redirect, %{to: "/auth/workos"}}} = live(conn, ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}") end - - test "execution show requires authentication", %{conn: conn} do - workspace_id = Ecto.UUID.generate() - workflow_id = Ecto.UUID.generate() - execution_id = Ecto.UUID.generate() - - assert {:error, {:redirect, %{to: "/auth/workos"}}} = - live( - conn, - ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/execution/#{execution_id}" - ) - end end diff --git a/test/fizz_web/live/workflow_execution_live_test.exs b/test/fizz_web/live/workflow_workspace_live_test.exs similarity index 67% rename from test/fizz_web/live/workflow_execution_live_test.exs rename to test/fizz_web/live/workflow_workspace_live_test.exs index da99da8..7c9c397 100644 --- a/test/fizz_web/live/workflow_execution_live_test.exs +++ b/test/fizz_web/live/workflow_workspace_live_test.exs @@ -1,10 +1,9 @@ -defmodule FizzWeb.WorkflowExecutionLiveTest do +defmodule FizzWeb.WorkflowWorkspaceLiveTest do use FizzWeb.ConnCase, async: true import Phoenix.LiveViewTest alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership} - alias Fizz.Executions alias Fizz.Repo alias Fizz.Workflows @@ -57,7 +56,7 @@ defmodule FizzWeb.WorkflowExecutionLiveTest do scope = scoped_workspace_access(user, workspace) conn = log_in_user(conn, user) - %{conn: conn, user: user, workspace: workspace, scope: scope} + %{conn: conn, workspace: workspace, scope: scope} end test "workflow index loads for authenticated workspace user", %{ @@ -84,45 +83,18 @@ defmodule FizzWeb.WorkflowExecutionLiveTest do assert to =~ ~r{^/workspaces/#{workspace.id}/workflows/[0-9a-f-]+$} end - test "workflow show renders workspace-scoped navigation links", %{ + test "workflow show renders workflow details without execution history", %{ conn: conn, workspace: workspace, scope: scope } do workflow = workflow_fixture!(scope) - execution = execution_fixture!(scope, workflow) {:ok, view, _html} = live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}") assert has_element?(view, "a[href='/workspaces/#{workspace.id}/workflows']") - - assert has_element?( - view, - "a[href='/workspaces/#{workspace.id}/workflows/#{workflow.id}/execution/#{execution.id}']" - ) - - refute has_element?(view, "a", "Run Workflow") - end - - test "execution show renders key sections and workflow links", %{ - conn: conn, - workspace: workspace, - scope: scope - } do - workflow = workflow_fixture!(scope) - execution = execution_fixture!(scope, workflow) - _step_execution = step_execution_fixture!(scope, execution) - - {:ok, view, _html} = - live( - conn, - ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/execution/#{execution.id}" - ) - - assert has_element?(view, "#execution-back-link") - assert has_element?(view, "#execution-workflow-link") - assert has_element?(view, "span", "Runtime unavailable") - assert has_element?(view, "#execution-step-list") + assert has_element?(view, "h2", "Workflow Details") + refute has_element?(view, "h2", "Recent Executions") end defp workspace_fixture!(user) do @@ -168,33 +140,4 @@ defmodule FizzWeb.WorkflowExecutionLiveTest do workflow end - - defp execution_fixture!(scope, workflow) do - {:ok, execution} = - Executions.create_execution(scope, %{ - workflow_id: workflow.id, - status: :running, - execution_type: :preview, - started_at: DateTime.utc_now(), - trigger: %{ - type: :manual, - data: %{"source" => "live_test"} - } - }) - - execution - end - - defp step_execution_fixture!(scope, execution) do - {:ok, step_execution} = - Executions.create_step_execution(scope, %{ - execution_id: execution.id, - step_id: "fetch_orders", - step_type_id: "http_request", - input_data: %{"page" => 1}, - metadata: %{"test" => true} - }) - - step_execution - end end From d0c9faddf992e78d4c8c9f77bfea018ac1631a86 Mon Sep 17 00:00:00 2001 From: Galad Dirie Date: Tue, 10 Mar 2026 18:20:52 -0400 Subject: [PATCH 003/135] remove migrations --- ...0260212030022_create_users_auth_tables.exs | 29 --- ...eate_identity_multi_tenancy_primitives.exs | 82 ------- ...ve_local_organizations_and_memberships.exs | 38 ---- ...20260212045946_drop_users_tokens_table.exs | 7 - .../migrations/20260212061655_add_oban.exs | 7 - ...215222153_create_sprites_broker_tables.exs | 210 ------------------ ...6051448_create_integration_connections.exs | 29 --- ...ake_sprite_name_unique_only_for_active.exs | 12 - ...move_sprites_usage_tracking_and_quotas.exs | 9 - ...ntegration_credentials_and_auth_method.exs | 50 ----- ...ake_integration_credentials_org_scoped.exs | 19 -- ...ake_integration_connections_org_scoped.exs | 53 ----- ..._integration_tables_to_auth_primitives.exs | 127 ----------- ...rop_oauth_connection_api_credential_fk.exs | 16 -- ...ovider_ids_and_clean_oauth_connections.exs | 80 ------- ..._multiple_api_credentials_per_provider.exs | 13 -- ...nique_display_label_to_api_credentials.exs | 9 - ...0_create_workflow_and_execution_tables.exs | 158 ------------- 18 files changed, 948 deletions(-) delete mode 100644 priv/repo/migrations/20260212030022_create_users_auth_tables.exs delete mode 100644 priv/repo/migrations/20260212030024_create_identity_multi_tenancy_primitives.exs delete mode 100644 priv/repo/migrations/20260212033908_remove_local_organizations_and_memberships.exs delete mode 100644 priv/repo/migrations/20260212045946_drop_users_tokens_table.exs delete mode 100644 priv/repo/migrations/20260212061655_add_oban.exs delete mode 100644 priv/repo/migrations/20260215222153_create_sprites_broker_tables.exs delete mode 100644 priv/repo/migrations/20260216051448_create_integration_connections.exs delete mode 100644 priv/repo/migrations/20260216193414_make_sprite_name_unique_only_for_active.exs delete mode 100644 priv/repo/migrations/20260216204034_remove_sprites_usage_tracking_and_quotas.exs delete mode 100644 priv/repo/migrations/20260218024916_add_integration_credentials_and_auth_method.exs delete mode 100644 priv/repo/migrations/20260218031428_make_integration_credentials_org_scoped.exs delete mode 100644 priv/repo/migrations/20260218040838_make_integration_connections_org_scoped.exs delete mode 100644 priv/repo/migrations/20260218041831_rename_integration_tables_to_auth_primitives.exs delete mode 100644 priv/repo/migrations/20260218042228_drop_oauth_connection_api_credential_fk.exs delete mode 100644 priv/repo/migrations/20260218044057_split_integration_provider_ids_and_clean_oauth_connections.exs delete mode 100644 priv/repo/migrations/20260218070538_allow_multiple_api_credentials_per_provider.exs delete mode 100644 priv/repo/migrations/20260218074215_add_user_scoped_unique_display_label_to_api_credentials.exs delete mode 100644 priv/repo/migrations/20260219032810_create_workflow_and_execution_tables.exs diff --git a/priv/repo/migrations/20260212030022_create_users_auth_tables.exs b/priv/repo/migrations/20260212030022_create_users_auth_tables.exs deleted file mode 100644 index 6c56696..0000000 --- a/priv/repo/migrations/20260212030022_create_users_auth_tables.exs +++ /dev/null @@ -1,29 +0,0 @@ -defmodule Fizz.Repo.Migrations.CreateUsersAuthTables do - use Ecto.Migration - - def change do - execute "CREATE EXTENSION IF NOT EXISTS citext", "" - - create table(:users, primary_key: false) do - add :id, :binary_id, primary_key: true - add :email, :citext, null: false - add :confirmed_at, :utc_datetime - - timestamps(type: :utc_datetime) - end - - create unique_index(:users, [:email]) - - create table(:users_tokens, primary_key: false) do - add :id, :binary_id, primary_key: true - add :user_id, references(:users, on_delete: :delete_all, type: :binary_id), null: false - add :token, :binary, null: false - add :authenticated_at, :utc_datetime - - timestamps(type: :utc_datetime, updated_at: false) - end - - create index(:users_tokens, [:user_id]) - create unique_index(:users_tokens, [:token]) - end -end diff --git a/priv/repo/migrations/20260212030024_create_identity_multi_tenancy_primitives.exs b/priv/repo/migrations/20260212030024_create_identity_multi_tenancy_primitives.exs deleted file mode 100644 index 967e423..0000000 --- a/priv/repo/migrations/20260212030024_create_identity_multi_tenancy_primitives.exs +++ /dev/null @@ -1,82 +0,0 @@ -defmodule Fizz.Repo.Migrations.CreateIdentityMultiTenancyPrimitives do - use Ecto.Migration - - def change do - alter table(:users) do - add :workos_user_id, :string - end - - create unique_index(:users, [:workos_user_id], where: "workos_user_id IS NOT NULL") - - create table(:organizations, primary_key: false) do - add :id, :binary_id, primary_key: true - add :name, :string, null: false - add :slug, :string, null: false - add :workos_organization_id, :string - add :metadata, :map, null: false, default: %{} - - timestamps(type: :utc_datetime) - end - - create unique_index(:organizations, [:slug]) - - create unique_index(:organizations, [:workos_organization_id], - where: "workos_organization_id IS NOT NULL" - ) - - create table(:workspaces, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :organization_id, references(:organizations, on_delete: :delete_all, type: :binary_id), - null: false - - add :name, :string, null: false - add :slug, :string, null: false - add :description, :string - add :metadata, :map, null: false, default: %{} - - timestamps(type: :utc_datetime) - end - - create index(:workspaces, [:organization_id]) - create unique_index(:workspaces, [:organization_id, :slug]) - - create table(:organization_memberships, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :organization_id, references(:organizations, on_delete: :delete_all, type: :binary_id), - null: false - - add :user_id, references(:users, on_delete: :delete_all, type: :binary_id), null: false - add :role, :string, null: false, default: "member" - add :workos_organization_membership_id, :string - - timestamps(type: :utc_datetime) - end - - create index(:organization_memberships, [:user_id]) - create unique_index(:organization_memberships, [:organization_id, :user_id]) - - create unique_index( - :organization_memberships, - [:workos_organization_membership_id], - where: "workos_organization_membership_id IS NOT NULL" - ) - - create table(:workspace_memberships, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :user_id, references(:users, on_delete: :delete_all, type: :binary_id), null: false - add :role, :string, null: false, default: "viewer" - add :access_purpose, :string - - timestamps(type: :utc_datetime) - end - - create index(:workspace_memberships, [:user_id]) - create unique_index(:workspace_memberships, [:workspace_id, :user_id]) - end -end diff --git a/priv/repo/migrations/20260212033908_remove_local_organizations_and_memberships.exs b/priv/repo/migrations/20260212033908_remove_local_organizations_and_memberships.exs deleted file mode 100644 index eb62a69..0000000 --- a/priv/repo/migrations/20260212033908_remove_local_organizations_and_memberships.exs +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Fizz.Repo.Migrations.RemoveLocalOrganizationsAndMemberships do - use Ecto.Migration - - def up do - alter table(:workspaces) do - add :workos_organization_id, :string - end - - execute(""" - UPDATE workspaces AS w - SET workos_organization_id = COALESCE(o.workos_organization_id, o.id::text) - FROM organizations AS o - WHERE w.organization_id = o.id - """) - - drop_if_exists constraint(:workspaces, "workspaces_organization_id_fkey") - drop_if_exists index(:workspaces, [:organization_id]) - drop_if_exists index(:workspaces, [:organization_id, :slug]) - - alter table(:workspaces) do - remove :organization_id - modify :workos_organization_id, :string, null: false - end - - create index(:workspaces, [:workos_organization_id]) - - create unique_index(:workspaces, [:workos_organization_id, :slug], - name: :workspaces_workos_organization_id_slug_index - ) - - drop_if_exists table(:organization_memberships) - drop_if_exists table(:organizations) - end - - def down do - raise "This migration is irreversible" - end -end diff --git a/priv/repo/migrations/20260212045946_drop_users_tokens_table.exs b/priv/repo/migrations/20260212045946_drop_users_tokens_table.exs deleted file mode 100644 index f221e5b..0000000 --- a/priv/repo/migrations/20260212045946_drop_users_tokens_table.exs +++ /dev/null @@ -1,7 +0,0 @@ -defmodule Fizz.Repo.Migrations.DropUsersTokensTable do - use Ecto.Migration - - def change do - drop table(:users_tokens) - end -end diff --git a/priv/repo/migrations/20260212061655_add_oban.exs b/priv/repo/migrations/20260212061655_add_oban.exs deleted file mode 100644 index f124cf1..0000000 --- a/priv/repo/migrations/20260212061655_add_oban.exs +++ /dev/null @@ -1,7 +0,0 @@ -defmodule Fizz.Repo.Migrations.AddOban do - use Ecto.Migration - - def up, do: Oban.Migration.up() - - def down, do: Oban.Migration.down(version: 1) -end diff --git a/priv/repo/migrations/20260215222153_create_sprites_broker_tables.exs b/priv/repo/migrations/20260215222153_create_sprites_broker_tables.exs deleted file mode 100644 index 147cce1..0000000 --- a/priv/repo/migrations/20260215222153_create_sprites_broker_tables.exs +++ /dev/null @@ -1,210 +0,0 @@ -defmodule Fizz.Repo.Migrations.CreateSpritesBrokerTables do - use Ecto.Migration - - def change do - create table(:sprites, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :created_by_user_id, references(:users, on_delete: :nilify_all, type: :binary_id) - add :name, :string, null: false - add :remote_name, :string, null: false - add :remote_id, :string - add :status, :string, null: false, default: "provisioning" - add :url, :string - add :url_auth_mode, :string - add :config, :map, null: false, default: %{} - add :metadata, :map, null: false, default: %{} - add :last_seen_at, :utc_datetime_usec - add :deleted_at, :utc_datetime_usec - - timestamps(type: :utc_datetime_usec) - end - - create unique_index(:sprites, [:remote_name]) - create unique_index(:sprites, [:workspace_id, :name]) - create index(:sprites, [:workspace_id, :status]) - - create table(:workspace_sprite_limits, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :max_sprites, :integer, null: false, default: 20 - add :max_concurrent_jobs, :integer, null: false, default: 5 - add :max_jobs_per_minute, :integer, null: false, default: 30 - add :max_console_sessions, :integer, null: false, default: 2 - add :max_services_per_sprite, :integer, null: false, default: 10 - add :max_checkpoints_per_sprite, :integer, null: false, default: 50 - add :daily_exec_seconds_limit, :bigint, null: false, default: 36_000 - add :daily_log_bytes_limit, :bigint, null: false, default: 2_147_483_648 - - timestamps(type: :utc_datetime_usec) - end - - create unique_index(:workspace_sprite_limits, [:workspace_id]) - - create table(:workspace_usage_daily, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :usage_date, :date, null: false - add :jobs_total, :integer, null: false, default: 0 - add :jobs_succeeded, :integer, null: false, default: 0 - add :jobs_failed, :integer, null: false, default: 0 - add :jobs_canceled, :integer, null: false, default: 0 - add :exec_seconds, :bigint, null: false, default: 0 - add :log_bytes, :bigint, null: false, default: 0 - add :console_seconds, :bigint, null: false, default: 0 - add :sprites_created, :integer, null: false, default: 0 - add :sprites_deleted, :integer, null: false, default: 0 - add :quota_rejections, :integer, null: false, default: 0 - add :rate_limited, :integer, null: false, default: 0 - - timestamps(type: :utc_datetime_usec) - end - - create unique_index(:workspace_usage_daily, [:workspace_id, :usage_date]) - - create table(:sprite_exec_jobs, primary_key: false) do - add :id, :binary_id, primary_key: true - add :sprite_id, references(:sprites, on_delete: :delete_all, type: :binary_id), null: false - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :requested_by_user_id, references(:users, on_delete: :nilify_all, type: :binary_id) - add :state, :string, null: false, default: "queued" - add :command, :string, null: false - add :args, {:array, :string}, null: false, default: [] - add :env, :map, null: false, default: %{} - add :dir, :string - add :tty, :boolean, null: false, default: false - add :timeout_ms, :integer - add :exit_code, :integer - add :remote_session_id, :string - add :bytes_stdout, :bigint, null: false, default: 0 - add :bytes_stderr, :bigint, null: false, default: 0 - add :bytes_total, :bigint, null: false, default: 0 - add :error_code, :string - add :error_message, :string - add :heartbeat_at, :utc_datetime_usec - add :started_at, :utc_datetime_usec - add :finished_at, :utc_datetime_usec - add :cancel_requested_at, :utc_datetime_usec - add :oban_job_id, :bigint - - timestamps(type: :utc_datetime_usec) - end - - create index(:sprite_exec_jobs, [:workspace_id, :inserted_at]) - create index(:sprite_exec_jobs, [:state, :heartbeat_at]) - create index(:sprite_exec_jobs, [:sprite_id, :state]) - create index(:sprite_exec_jobs, [:oban_job_id]) - - create table(:sprite_exec_log_chunks, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :job_id, references(:sprite_exec_jobs, on_delete: :delete_all, type: :binary_id), - null: false - - add :seq, :bigint, null: false - add :stream, :string, null: false - add :chunk, :binary, null: false - add :byte_size, :integer, null: false - add :inserted_at, :utc_datetime_usec, null: false, default: fragment("NOW()") - end - - create unique_index(:sprite_exec_log_chunks, [:job_id, :seq]) - create index(:sprite_exec_log_chunks, [:inserted_at]) - - create table(:sprite_console_sessions, primary_key: false) do - add :id, :binary_id, primary_key: true - add :sprite_id, references(:sprites, on_delete: :delete_all, type: :binary_id), null: false - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :opened_by_user_id, references(:users, on_delete: :nilify_all, type: :binary_id) - add :remote_session_id, :string - add :state, :string, null: false, default: "active" - add :opened_at, :utc_datetime_usec - add :closed_at, :utc_datetime_usec - add :close_reason, :string - add :rows, :integer, null: false, default: 24 - add :cols, :integer, null: false, default: 80 - - timestamps(type: :utc_datetime_usec) - end - - create index(:sprite_console_sessions, [:workspace_id, :state]) - create index(:sprite_console_sessions, [:sprite_id, :state]) - - create table(:sprite_services, primary_key: false) do - add :id, :binary_id, primary_key: true - add :sprite_id, references(:sprites, on_delete: :delete_all, type: :binary_id), null: false - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :name, :string, null: false - add :cmd, :string - add :args, {:array, :string}, null: false, default: [] - add :needs, {:array, :string}, null: false, default: [] - add :status, :string, null: false, default: "stopped" - add :published, :boolean, null: false, default: false - add :metadata, :map, null: false, default: %{} - add :last_started_at, :utc_datetime_usec - add :last_stopped_at, :utc_datetime_usec - - timestamps(type: :utc_datetime_usec) - end - - create unique_index(:sprite_services, [:sprite_id, :name]) - create index(:sprite_services, [:workspace_id, :status]) - - create table(:sprite_checkpoints, primary_key: false) do - add :id, :binary_id, primary_key: true - add :sprite_id, references(:sprites, on_delete: :delete_all, type: :binary_id), null: false - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :remote_checkpoint_id, :string, null: false - add :comment, :string - add :created_at_remote, :utc_datetime_usec - add :created_by_user_id, references(:users, on_delete: :nilify_all, type: :binary_id) - - timestamps(type: :utc_datetime_usec) - end - - create unique_index(:sprite_checkpoints, [:sprite_id, :remote_checkpoint_id]) - create index(:sprite_checkpoints, [:workspace_id, :inserted_at]) - - create table(:workspace_rate_limit_windows, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :bucket, :string, null: false - add :window_start, :utc_datetime_usec, null: false - add :count, :integer, null: false, default: 0 - - timestamps(type: :utc_datetime_usec) - end - - create unique_index( - :workspace_rate_limit_windows, - [:workspace_id, :bucket, :window_start], - name: :workspace_rate_limit_windows_ws_bucket_window_idx - ) - - create index(:workspace_rate_limit_windows, [:workspace_id, :window_start]) - end -end diff --git a/priv/repo/migrations/20260216051448_create_integration_connections.exs b/priv/repo/migrations/20260216051448_create_integration_connections.exs deleted file mode 100644 index 1d3bf30..0000000 --- a/priv/repo/migrations/20260216051448_create_integration_connections.exs +++ /dev/null @@ -1,29 +0,0 @@ -defmodule Fizz.Repo.Migrations.CreateIntegrationConnections do - use Ecto.Migration - - def change do - create table(:integration_connections, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :user_id, references(:users, on_delete: :delete_all, type: :binary_id), null: false - - add :provider, :string, null: false - add :status, :string, null: false, default: "active" - add :scopes, {:array, :string}, default: [] - add :missing_scopes, {:array, :string}, default: [] - add :provider_metadata, :map, default: %{} - add :last_token_fetch_at, :utc_datetime_usec - add :last_error, :string - add :disconnected_at, :utc_datetime_usec - - timestamps(type: :utc_datetime_usec) - end - - create unique_index(:integration_connections, [:workspace_id, :user_id, :provider]) - create index(:integration_connections, [:workspace_id, :provider]) - create index(:integration_connections, [:user_id, :provider]) - end -end diff --git a/priv/repo/migrations/20260216193414_make_sprite_name_unique_only_for_active.exs b/priv/repo/migrations/20260216193414_make_sprite_name_unique_only_for_active.exs deleted file mode 100644 index bc7f881..0000000 --- a/priv/repo/migrations/20260216193414_make_sprite_name_unique_only_for_active.exs +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Fizz.Repo.Migrations.MakeSpriteNameUniqueOnlyForActive do - use Ecto.Migration - - def change do - drop unique_index(:sprites, [:workspace_id, :name]) - - create unique_index(:sprites, [:workspace_id, :name], - where: "deleted_at IS NULL", - name: :sprites_workspace_id_name_index - ) - end -end diff --git a/priv/repo/migrations/20260216204034_remove_sprites_usage_tracking_and_quotas.exs b/priv/repo/migrations/20260216204034_remove_sprites_usage_tracking_and_quotas.exs deleted file mode 100644 index 7957dc8..0000000 --- a/priv/repo/migrations/20260216204034_remove_sprites_usage_tracking_and_quotas.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Fizz.Repo.Migrations.RemoveSpritesUsageTrackingAndQuotas do - use Ecto.Migration - - def change do - drop table(:workspace_rate_limit_windows) - drop table(:workspace_usage_daily) - drop table(:workspace_sprite_limits) - end -end diff --git a/priv/repo/migrations/20260218024916_add_integration_credentials_and_auth_method.exs b/priv/repo/migrations/20260218024916_add_integration_credentials_and_auth_method.exs deleted file mode 100644 index 070523a..0000000 --- a/priv/repo/migrations/20260218024916_add_integration_credentials_and_auth_method.exs +++ /dev/null @@ -1,50 +0,0 @@ -defmodule Fizz.Repo.Migrations.AddIntegrationCredentialsAndAuthMethod do - use Ecto.Migration - - def change do - create table(:integration_credentials, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :user_id, references(:users, on_delete: :delete_all, type: :binary_id), null: false - add :workos_organization_id, :string, null: false - add :provider, :string, null: false - add :provider_label, :string, null: false - add :provider_custom_name, :string - add :vault_object_id, :string, null: false - add :vault_object_name, :string, null: false - add :vault_version, :string - add :last_used_at, :utc_datetime_usec - - timestamps(type: :utc_datetime_usec) - end - - create unique_index(:integration_credentials, [:workspace_id, :user_id, :provider], - name: :integration_credentials_workspace_user_provider_index - ) - - create unique_index(:integration_credentials, [:vault_object_id]) - create unique_index(:integration_credentials, [:vault_object_name]) - create index(:integration_credentials, [:workspace_id, :provider]) - create index(:integration_credentials, [:user_id, :provider]) - create index(:integration_credentials, [:workos_organization_id]) - - alter table(:integration_connections) do - add :auth_method, :string, null: false, default: "oauth" - - add :credential_id, - references(:integration_credentials, on_delete: :nilify_all, type: :binary_id) - end - - create index(:integration_connections, [:credential_id]) - - create constraint( - :integration_connections, - :integration_connections_auth_method_credential_check, - check: - "(auth_method = 'oauth' AND credential_id IS NULL) OR (auth_method = 'api_key' AND credential_id IS NOT NULL)" - ) - end -end diff --git a/priv/repo/migrations/20260218031428_make_integration_credentials_org_scoped.exs b/priv/repo/migrations/20260218031428_make_integration_credentials_org_scoped.exs deleted file mode 100644 index 69bdddb..0000000 --- a/priv/repo/migrations/20260218031428_make_integration_credentials_org_scoped.exs +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Fizz.Repo.Migrations.MakeIntegrationCredentialsOrgScoped do - use Ecto.Migration - - def change do - drop_if_exists index(:integration_credentials, [:workspace_id, :provider]) - - drop_if_exists unique_index(:integration_credentials, [:workspace_id, :user_id, :provider], - name: :integration_credentials_workspace_user_provider_index - ) - - execute("ALTER TABLE integration_credentials DROP COLUMN IF EXISTS workspace_id") - - create unique_index(:integration_credentials, [:workos_organization_id, :user_id, :provider], - name: :integration_credentials_org_user_provider_index - ) - - create index(:integration_credentials, [:workos_organization_id, :provider]) - end -end diff --git a/priv/repo/migrations/20260218040838_make_integration_connections_org_scoped.exs b/priv/repo/migrations/20260218040838_make_integration_connections_org_scoped.exs deleted file mode 100644 index e860c3b..0000000 --- a/priv/repo/migrations/20260218040838_make_integration_connections_org_scoped.exs +++ /dev/null @@ -1,53 +0,0 @@ -defmodule Fizz.Repo.Migrations.MakeIntegrationConnectionsOrgScoped do - use Ecto.Migration - - def up do - drop_if_exists index(:integration_connections, [:workspace_id, :provider]) - drop_if_exists index(:integration_connections, [:workspace_id, :user_id, :provider]) - - alter table(:integration_connections) do - add :workos_organization_id, :string - end - - execute(""" - UPDATE integration_connections AS connection - SET workos_organization_id = workspace.workos_organization_id - FROM workspaces AS workspace - WHERE connection.workspace_id = workspace.id - AND connection.workos_organization_id IS NULL - """) - - execute(""" - ALTER TABLE integration_connections - ALTER COLUMN workos_organization_id SET NOT NULL - """) - - alter table(:integration_connections) do - remove :workspace_id - end - - create unique_index(:integration_connections, [:workos_organization_id, :user_id, :provider], - name: :integration_connections_org_user_provider_index - ) - - create index(:integration_connections, [:workos_organization_id, :provider]) - end - - def down do - drop_if_exists index(:integration_connections, [:workos_organization_id, :provider]) - - drop_if_exists unique_index( - :integration_connections, - [:workos_organization_id, :user_id, :provider], - name: :integration_connections_org_user_provider_index - ) - - alter table(:integration_connections) do - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id) - remove :workos_organization_id - end - - create unique_index(:integration_connections, [:workspace_id, :user_id, :provider]) - create index(:integration_connections, [:workspace_id, :provider]) - end -end diff --git a/priv/repo/migrations/20260218041831_rename_integration_tables_to_auth_primitives.exs b/priv/repo/migrations/20260218041831_rename_integration_tables_to_auth_primitives.exs deleted file mode 100644 index 9c523c1..0000000 --- a/priv/repo/migrations/20260218041831_rename_integration_tables_to_auth_primitives.exs +++ /dev/null @@ -1,127 +0,0 @@ -defmodule Fizz.Repo.Migrations.RenameIntegrationTablesToAuthPrimitives do - use Ecto.Migration - - def up do - rename table(:integration_credentials), to: table(:api_credentials) - rename table(:integration_connections), to: table(:oauth_connections) - rename table(:oauth_connections), :credential_id, to: :api_credential_id - - execute( - "ALTER TABLE api_credentials RENAME CONSTRAINT integration_credentials_user_id_fkey TO api_credentials_user_id_fkey" - ) - - execute( - "ALTER TABLE oauth_connections RENAME CONSTRAINT integration_connections_user_id_fkey TO oauth_connections_user_id_fkey" - ) - - execute( - "ALTER TABLE oauth_connections RENAME CONSTRAINT integration_connections_credential_id_fkey TO oauth_connections_api_credential_id_fkey" - ) - - execute( - "ALTER TABLE oauth_connections RENAME CONSTRAINT integration_connections_auth_method_credential_check TO oauth_connections_auth_method_api_credential_check" - ) - - execute( - "ALTER INDEX IF EXISTS integration_credentials_org_user_provider_index RENAME TO api_credentials_org_user_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS integration_credentials_vault_object_id_index RENAME TO api_credentials_vault_object_id_index" - ) - - execute( - "ALTER INDEX IF EXISTS integration_credentials_vault_object_name_index RENAME TO api_credentials_vault_object_name_index" - ) - - execute( - "ALTER INDEX IF EXISTS integration_credentials_user_id_provider_index RENAME TO api_credentials_user_id_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS integration_credentials_workos_organization_id_index RENAME TO api_credentials_workos_organization_id_index" - ) - - execute( - "ALTER INDEX IF EXISTS integration_credentials_workos_organization_id_provider_index RENAME TO api_credentials_workos_organization_id_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS integration_connections_org_user_provider_index RENAME TO oauth_connections_org_user_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS integration_connections_user_id_provider_index RENAME TO oauth_connections_user_id_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS integration_connections_credential_id_index RENAME TO oauth_connections_api_credential_id_index" - ) - - execute( - "ALTER INDEX IF EXISTS integration_connections_workos_organization_id_provider_index RENAME TO oauth_connections_workos_organization_id_provider_index" - ) - end - - def down do - execute( - "ALTER INDEX IF EXISTS oauth_connections_workos_organization_id_provider_index RENAME TO integration_connections_workos_organization_id_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS oauth_connections_api_credential_id_index RENAME TO integration_connections_credential_id_index" - ) - - execute( - "ALTER INDEX IF EXISTS oauth_connections_user_id_provider_index RENAME TO integration_connections_user_id_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS oauth_connections_org_user_provider_index RENAME TO integration_connections_org_user_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS api_credentials_workos_organization_id_provider_index RENAME TO integration_credentials_workos_organization_id_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS api_credentials_workos_organization_id_index RENAME TO integration_credentials_workos_organization_id_index" - ) - - execute( - "ALTER INDEX IF EXISTS api_credentials_user_id_provider_index RENAME TO integration_credentials_user_id_provider_index" - ) - - execute( - "ALTER INDEX IF EXISTS api_credentials_vault_object_name_index RENAME TO integration_credentials_vault_object_name_index" - ) - - execute( - "ALTER INDEX IF EXISTS api_credentials_vault_object_id_index RENAME TO integration_credentials_vault_object_id_index" - ) - - execute( - "ALTER INDEX IF EXISTS api_credentials_org_user_provider_index RENAME TO integration_credentials_org_user_provider_index" - ) - - execute( - "ALTER TABLE oauth_connections RENAME CONSTRAINT oauth_connections_auth_method_api_credential_check TO integration_connections_auth_method_credential_check" - ) - - execute( - "ALTER TABLE oauth_connections RENAME CONSTRAINT oauth_connections_api_credential_id_fkey TO integration_connections_credential_id_fkey" - ) - - execute( - "ALTER TABLE oauth_connections RENAME CONSTRAINT oauth_connections_user_id_fkey TO integration_connections_user_id_fkey" - ) - - execute( - "ALTER TABLE api_credentials RENAME CONSTRAINT api_credentials_user_id_fkey TO integration_credentials_user_id_fkey" - ) - - rename table(:oauth_connections), :api_credential_id, to: :credential_id - rename table(:oauth_connections), to: table(:integration_connections) - rename table(:api_credentials), to: table(:integration_credentials) - end -end diff --git a/priv/repo/migrations/20260218042228_drop_oauth_connection_api_credential_fk.exs b/priv/repo/migrations/20260218042228_drop_oauth_connection_api_credential_fk.exs deleted file mode 100644 index 062a4c7..0000000 --- a/priv/repo/migrations/20260218042228_drop_oauth_connection_api_credential_fk.exs +++ /dev/null @@ -1,16 +0,0 @@ -defmodule Fizz.Repo.Migrations.DropOauthConnectionApiCredentialFk do - use Ecto.Migration - - def change do - execute( - "ALTER TABLE oauth_connections DROP CONSTRAINT IF EXISTS oauth_connections_api_credential_id_fkey", - """ - ALTER TABLE oauth_connections - ADD CONSTRAINT oauth_connections_api_credential_id_fkey - FOREIGN KEY (api_credential_id) - REFERENCES api_credentials(id) - ON DELETE SET NULL - """ - ) - end -end diff --git a/priv/repo/migrations/20260218044057_split_integration_provider_ids_and_clean_oauth_connections.exs b/priv/repo/migrations/20260218044057_split_integration_provider_ids_and_clean_oauth_connections.exs deleted file mode 100644 index 3e92597..0000000 --- a/priv/repo/migrations/20260218044057_split_integration_provider_ids_and_clean_oauth_connections.exs +++ /dev/null @@ -1,80 +0,0 @@ -defmodule Fizz.Repo.Migrations.SplitIntegrationProviderIdsAndCleanOauthConnections do - use Ecto.Migration - - def up do - execute(""" - UPDATE api_credentials - SET provider = CASE provider - WHEN 'github' THEN 'github_api_key' - WHEN 'openai' THEN 'openai_api_key' - WHEN 'anthropic' THEN 'anthropic_api_key' - WHEN 'custom' THEN 'custom_api_key' - ELSE provider - END - """) - - execute("DELETE FROM oauth_connections WHERE auth_method = 'api_key'") - - execute(""" - UPDATE oauth_connections - SET provider = CASE provider - WHEN 'github' THEN 'github_oauth' - ELSE provider - END - """) - - drop_if_exists index(:oauth_connections, [:api_credential_id]) - - execute( - "ALTER TABLE oauth_connections DROP CONSTRAINT IF EXISTS oauth_connections_auth_method_api_credential_check" - ) - - execute( - "ALTER TABLE oauth_connections DROP CONSTRAINT IF EXISTS oauth_connections_api_credential_id_fkey" - ) - - alter table(:oauth_connections) do - remove :auth_method - remove :api_credential_id - end - end - - def down do - alter table(:oauth_connections) do - add :auth_method, :string, null: false, default: "oauth" - - add :api_credential_id, - references(:api_credentials, on_delete: :nilify_all, type: :binary_id) - end - - create index(:oauth_connections, [:api_credential_id]) - - execute(""" - ALTER TABLE oauth_connections - ADD CONSTRAINT oauth_connections_auth_method_api_credential_check - CHECK ( - (auth_method = 'oauth' AND api_credential_id IS NULL) OR - (auth_method = 'api_key' AND api_credential_id IS NOT NULL) - ) - """) - - execute(""" - UPDATE oauth_connections - SET provider = CASE provider - WHEN 'github_oauth' THEN 'github' - ELSE provider - END - """) - - execute(""" - UPDATE api_credentials - SET provider = CASE provider - WHEN 'github_api_key' THEN 'github' - WHEN 'openai_api_key' THEN 'openai' - WHEN 'anthropic_api_key' THEN 'anthropic' - WHEN 'custom_api_key' THEN 'custom' - ELSE provider - END - """) - end -end diff --git a/priv/repo/migrations/20260218070538_allow_multiple_api_credentials_per_provider.exs b/priv/repo/migrations/20260218070538_allow_multiple_api_credentials_per_provider.exs deleted file mode 100644 index 8bb412d..0000000 --- a/priv/repo/migrations/20260218070538_allow_multiple_api_credentials_per_provider.exs +++ /dev/null @@ -1,13 +0,0 @@ -defmodule Fizz.Repo.Migrations.AllowMultipleApiCredentialsPerProvider do - use Ecto.Migration - - def change do - drop_if_exists index(:api_credentials, [:workos_organization_id, :user_id, :provider], - name: :api_credentials_org_user_provider_index - ) - - create index(:api_credentials, [:workos_organization_id, :user_id, :provider], - name: :api_credentials_org_user_provider_index - ) - end -end diff --git a/priv/repo/migrations/20260218074215_add_user_scoped_unique_display_label_to_api_credentials.exs b/priv/repo/migrations/20260218074215_add_user_scoped_unique_display_label_to_api_credentials.exs deleted file mode 100644 index 1913348..0000000 --- a/priv/repo/migrations/20260218074215_add_user_scoped_unique_display_label_to_api_credentials.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Fizz.Repo.Migrations.AddUserScopedUniqueDisplayLabelToApiCredentials do - use Ecto.Migration - - def change do - create unique_index(:api_credentials, [:workos_organization_id, :user_id, :provider_label], - name: :api_credentials_org_user_provider_label_index - ) - end -end diff --git a/priv/repo/migrations/20260219032810_create_workflow_and_execution_tables.exs b/priv/repo/migrations/20260219032810_create_workflow_and_execution_tables.exs deleted file mode 100644 index c4c5da9..0000000 --- a/priv/repo/migrations/20260219032810_create_workflow_and_execution_tables.exs +++ /dev/null @@ -1,158 +0,0 @@ -defmodule Fizz.Repo.Migrations.CreateWorkflowAndExecutionTables do - use Ecto.Migration - - def change do - create table(:workflows, primary_key: false) do - add :id, :binary_id, primary_key: true - add :name, :string, null: false - add :description, :string - add :status, :string, null: false, default: "draft" - add :current_version_tag, :string - add :published_version_id, :binary_id - - add :workspace_id, references(:workspaces, on_delete: :delete_all, type: :binary_id), - null: false - - add :user_id, references(:users, on_delete: :nothing, type: :binary_id), null: false - - timestamps(type: :utc_datetime_usec) - end - - create index(:workflows, [:workspace_id]) - create index(:workflows, [:user_id]) - create index(:workflows, [:status]) - create index(:workflows, [:published_version_id]) - create index(:workflows, [:workspace_id, :user_id]) - - create table(:workflow_versions, primary_key: false) do - add :id, :binary_id, primary_key: true - add :version_tag, :string, null: false - add :source_hash, :string, null: false - add :steps, {:array, :map}, null: false, default: [] - add :connections, {:array, :map}, null: false, default: [] - add :groups, {:array, :map}, null: false, default: [] - add :changelog, :string - add :published_at, :utc_datetime_usec - add :published_by, references(:users, on_delete: :nilify_all, type: :binary_id) - - add :workflow_id, references(:workflows, on_delete: :delete_all, type: :binary_id), - null: false - - timestamps(type: :utc_datetime_usec, updated_at: false) - end - - create index(:workflow_versions, [:workflow_id]) - create index(:workflow_versions, [:published_by]) - create index(:workflow_versions, [:workflow_id, :published_at]) - create unique_index(:workflow_versions, [:workflow_id, :version_tag]) - - create table(:workflow_drafts, primary_key: false) do - add :workflow_id, references(:workflows, on_delete: :delete_all, type: :binary_id), - primary_key: true, - null: false - - add :steps, {:array, :map}, null: false, default: [] - add :connections, {:array, :map}, null: false, default: [] - add :groups, {:array, :map}, null: false, default: [] - add :editor_state, :map, null: false, default: %{} - - add :settings, :map, - null: false, - default: %{"timeout_ms" => 300_000, "max_retries" => 3} - - timestamps(type: :utc_datetime_usec) - end - - execute( - """ - ALTER TABLE workflows - ADD CONSTRAINT workflows_published_version_id_fkey - FOREIGN KEY (published_version_id) - REFERENCES workflow_versions(id) - ON DELETE SET NULL - """, - """ - ALTER TABLE workflows - DROP CONSTRAINT IF EXISTS workflows_published_version_id_fkey - """ - ) - - create table(:executions, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :workflow_id, references(:workflows, on_delete: :delete_all, type: :binary_id), - null: false - - add :status, :string, null: false, default: "pending" - add :execution_type, :string, null: false, default: "production" - add :trigger, :map, null: false - add :metadata, :map - add :context, :map, null: false, default: %{} - add :output, :map - add :error, :map - add :waiting_for, :map - add :started_at, :utc_datetime_usec - add :completed_at, :utc_datetime_usec - add :expires_at, :utc_datetime_usec - add :triggered_by_user_id, references(:users, on_delete: :nilify_all, type: :binary_id) - - timestamps(type: :utc_datetime_usec) - end - - create index(:executions, [:workflow_id]) - create index(:executions, [:status]) - create index(:executions, [:execution_type]) - create index(:executions, [:triggered_by_user_id]) - create index(:executions, [:workflow_id, :inserted_at]) - - create table(:step_executions, primary_key: false) do - add :id, :binary_id, primary_key: true - - add :execution_id, references(:executions, on_delete: :delete_all, type: :binary_id), - null: false - - add :step_id, :string, null: false - add :step_type_id, :string, null: false - add :status, :string, null: false, default: "pending" - add :input_data, :map - add :output_data, :map - add :output_item_count, :integer - add :item_index, :integer - add :items_total, :integer - add :error, :map - add :metadata, :map, null: false, default: %{} - add :queued_at, :utc_datetime_usec - add :started_at, :utc_datetime_usec - add :completed_at, :utc_datetime_usec - add :attempt, :integer, null: false, default: 1 - add :retry_of_id, :binary_id - - timestamps(type: :utc_datetime_usec) - end - - create index(:step_executions, [:execution_id]) - create index(:step_executions, [:execution_id, :step_id]) - create index(:step_executions, [:execution_id, :status]) - create index(:step_executions, [:execution_id, :inserted_at]) - create index(:step_executions, [:execution_id, :step_id, :attempt, :item_index, :inserted_at]) - - create table(:edit_operations, primary_key: false) do - add :id, :binary_id, primary_key: true - add :operation_id, :string, null: false - add :seq, :integer, null: false - add :type, :string, null: false - add :payload, :map, null: false - add :user_id, :binary_id - add :client_seq, :integer - - add :workflow_id, references(:workflows, on_delete: :delete_all, type: :binary_id), - null: false - - timestamps(type: :utc_datetime_usec, updated_at: false) - end - - create unique_index(:edit_operations, [:operation_id]) - create unique_index(:edit_operations, [:workflow_id, :seq]) - create index(:edit_operations, [:workflow_id]) - end -end From 5af50a7ac833814e70546ef33a5a3f6189d1dcbd Mon Sep 17 00:00:00 2001 From: Galad Dirie Date: Tue, 10 Mar 2026 18:21:50 -0400 Subject: [PATCH 004/135] remove workflow contract --- lib/fizz/workflows/contract.ex | 101 ------------------ .../workflow_contract_controller.ex | 22 ---- lib/fizz_web/router.ex | 7 -- 3 files changed, 130 deletions(-) delete mode 100644 lib/fizz/workflows/contract.ex delete mode 100644 lib/fizz_web/controllers/workflow_contract_controller.ex diff --git a/lib/fizz/workflows/contract.ex b/lib/fizz/workflows/contract.ex deleted file mode 100644 index fd5b9bb..0000000 --- a/lib/fizz/workflows/contract.ex +++ /dev/null @@ -1,101 +0,0 @@ -defmodule Fizz.Workflows.Contract do - @moduledoc """ - Represents the derived input/output contract for a workflow draft. - """ - - alias Fizz.Workflows.WorkflowDraft - alias Fizz.Steps.Executors.Behaviour, as: ExecutorBehaviour - - @type input_def :: %{ - name: String.t(), - trigger_type: String.t(), - trigger_step_id: String.t(), - schema: map() | nil, - description: String.t() | nil - } - - @type output_def :: %{ - name: String.t(), - output_step_id: String.t(), - schema: map() | nil, - description: String.t() | nil - } - - @type t :: %__MODULE__{ - inputs: [input_def()], - outputs: [output_def()] - } - - @derive Jason.Encoder - defstruct inputs: [], outputs: [] - - @trigger_type_ids ["manual_input", "schedule_trigger", "event_trigger"] - @output_type_ids ["workflow_output", "data_output"] - - @doc """ - Derives the contract from a workflow draft. - """ - @spec derive(WorkflowDraft.t()) :: t() - def derive(%WorkflowDraft{} = draft) do - steps = draft.steps || [] - - inputs = - steps - |> Enum.filter(&trigger_step?/1) - |> Enum.map(&input_from_trigger/1) - - outputs = - steps - |> Enum.filter(&output_step?/1) - |> Enum.map(&output_from_step/1) - - %__MODULE__{inputs: inputs, outputs: outputs} - end - - defp trigger_step?(%{type_id: type_id}), do: type_id in @trigger_type_ids - defp output_step?(%{type_id: type_id}), do: type_id in @output_type_ids - - defp input_from_trigger(step) do - schema = effective_output_schema(step.type_id, step.config || %{}) - - %{ - name: step.name, - trigger_type: step.type_id, - trigger_step_id: step.id, - schema: schema, - description: step.notes - } - end - - defp output_from_step(step) do - config = step.config || %{} - - %{ - name: Map.get(config, "name", "output"), - output_step_id: step.id, - schema: Map.get(config, "schema"), - description: step.notes - } - end - - defp effective_output_schema(type_id, config) do - case ExecutorBehaviour.resolve(type_id) do - {:ok, executor} -> - if function_exported?(executor, :effective_output_schema, 1) do - executor.effective_output_schema(config) - else - registry_output_schema(type_id) - end - - {:error, _reason} -> - registry_output_schema(type_id) - end - end - - defp registry_output_schema(type_id) do - case Fizz.Steps.Registry.get(type_id) do - {:ok, type} -> type.output_schema - {:error, :not_found} -> nil - end - end -end diff --git a/lib/fizz_web/controllers/workflow_contract_controller.ex b/lib/fizz_web/controllers/workflow_contract_controller.ex deleted file mode 100644 index c24e3e3..0000000 --- a/lib/fizz_web/controllers/workflow_contract_controller.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule FizzWeb.WorkflowContractController do - use FizzWeb, :controller - - alias Fizz.Workflows - alias Fizz.Workflows.{Contract, WorkflowDraft} - - def show(conn, %{"id" => id}) do - scope = conn.assigns.current_scope - - case Workflows.get_workflow_with_draft(scope, id) do - {:ok, workflow} -> - draft = workflow.draft || %WorkflowDraft{steps: []} - contract = Contract.derive(draft) - json(conn, contract) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "not_found"}) - end - end -end diff --git a/lib/fizz_web/router.ex b/lib/fizz_web/router.ex index 3948901..33c4efd 100644 --- a/lib/fizz_web/router.ex +++ b/lib/fizz_web/router.ex @@ -51,13 +51,6 @@ defmodule FizzWeb.Router do end end - ## Authentication routes - scope "/api", FizzWeb do - pipe_through :api - - get "/workflows/:id/contract", WorkflowContractController, :show - end - scope "/", FizzWeb do pipe_through [:browser] From f1182f58171a4e460c533b413b93628296047abc Mon Sep 17 00:00:00 2001 From: Galad Dirie Date: Thu, 12 Mar 2026 13:09:12 -0400 Subject: [PATCH 005/135] Remove workflows context and related modules --- lib/fizz/accounts/scope.ex | 59 -- lib/fizz/steps/executors/workflow_output.ex | 72 -- lib/fizz/steps/registry.ex | 1 - lib/fizz/workflows.ex | 623 ------------------ lib/fizz/workflows/embeds/connection.ex | 41 -- lib/fizz/workflows/embeds/node_group.ex | 78 --- lib/fizz/workflows/embeds/step.ex | 60 -- lib/fizz/workflows/validator.ex | 443 ------------- lib/fizz/workflows/workflow.ex | 97 --- lib/fizz/workflows/workflow_draft.ex | 74 --- lib/fizz/workflows/workflow_version.ex | 113 ---- lib/fizz_web/formatters.ex | 103 --- lib/fizz_web/live/workflow_live/index.ex | 333 ---------- lib/fizz_web/live/workflow_live/paths.ex | 10 - lib/fizz_web/live/workflow_live/show.ex | 151 ----- .../live/workspaces_live/index.html.heex | 9 +- .../live/workspaces_live/show.html.heex | 11 +- lib/fizz_web/router.ex | 3 - priv/repo/seeds.exs | 329 +-------- test/fizz/accounts/scope_test.exs | 56 -- .../workflows/validator_subnodes_test.exs | 140 ---- test/fizz/workflows_test.exs | 65 -- test/fizz_web/live/workflow_live_test.exs | 20 - .../live/workflow_workspace_live_test.exs | 143 ---- 24 files changed, 8 insertions(+), 3026 deletions(-) delete mode 100644 lib/fizz/steps/executors/workflow_output.ex delete mode 100644 lib/fizz/workflows.ex delete mode 100644 lib/fizz/workflows/embeds/connection.ex delete mode 100644 lib/fizz/workflows/embeds/node_group.ex delete mode 100644 lib/fizz/workflows/embeds/step.ex delete mode 100644 lib/fizz/workflows/validator.ex delete mode 100644 lib/fizz/workflows/workflow.ex delete mode 100644 lib/fizz/workflows/workflow_draft.ex delete mode 100644 lib/fizz/workflows/workflow_version.ex delete mode 100644 lib/fizz_web/formatters.ex delete mode 100644 lib/fizz_web/live/workflow_live/index.ex delete mode 100644 lib/fizz_web/live/workflow_live/paths.ex delete mode 100644 lib/fizz_web/live/workflow_live/show.ex delete mode 100644 test/fizz/workflows/validator_subnodes_test.exs delete mode 100644 test/fizz/workflows_test.exs delete mode 100644 test/fizz_web/live/workflow_live_test.exs delete mode 100644 test/fizz_web/live/workflow_workspace_live_test.exs diff --git a/lib/fizz/accounts/scope.ex b/lib/fizz/accounts/scope.ex index 80821aa..f0a8b10 100644 --- a/lib/fizz/accounts/scope.ex +++ b/lib/fizz/accounts/scope.ex @@ -7,12 +7,9 @@ defmodule Fizz.Accounts.Scope do """ alias Fizz.Accounts.{User, Workspace} - alias Fizz.Workflows.Workflow @organization_roles [:owner, :admin, :member] @workspace_roles [:admin, :member, :viewer] - @workspace_view_roles [:admin, :member, :viewer] - @workspace_edit_roles [:admin, :member] defstruct user: nil, actor: :anonymous, @@ -111,60 +108,4 @@ defmodule Fizz.Accounts.Scope do @spec workspace_admin?(t()) :: boolean() def workspace_admin?(%__MODULE__{workspace_role: :admin}), do: true def workspace_admin?(%__MODULE__{}), do: false - - @doc """ - Whether scope can view a workflow in the active workspace. - """ - @spec can_view_workflow?(t() | nil, Workflow.t() | map()) :: boolean() - def can_view_workflow?(%__MODULE__{} = scope, %Workflow{} = workflow) do - authenticated?(scope) and same_workspace?(scope, workflow) and can_read_workspace?(scope) - end - - def can_view_workflow?(%__MODULE__{} = scope, %{workspace_id: _workspace_id} = workflow) do - authenticated?(scope) and same_workspace?(scope, workflow) and can_read_workspace?(scope) - end - - def can_view_workflow?(_scope, _workflow), do: false - - @doc """ - Whether scope can edit workflows in the active workspace. - """ - @spec can_edit_workflow?(t() | nil, Workflow.t() | map()) :: boolean() - def can_edit_workflow?(%__MODULE__{} = scope, %Workflow{} = workflow) do - authenticated?(scope) and same_workspace?(scope, workflow) and can_write_workspace?(scope) - end - - def can_edit_workflow?(%__MODULE__{} = scope, %{workspace_id: _workspace_id} = workflow) do - authenticated?(scope) and same_workspace?(scope, workflow) and can_write_workspace?(scope) - end - - def can_edit_workflow?(_scope, _workflow), do: false - - @doc """ - Compatibility helper kept for call sites that previously used owner checks. - - Under workspace-scoped auth, delete-level access follows edit permissions. - """ - @spec owns_workflow?(t() | nil, Workflow.t() | map()) :: boolean() - def owns_workflow?(scope, workflow), do: can_edit_workflow?(scope, workflow) - - defp same_workspace?(%__MODULE__{workspace: %Workspace{id: scope_workspace_id}}, %{ - workspace_id: workflow_workspace_id - }) - when is_binary(scope_workspace_id) and is_binary(workflow_workspace_id), - do: scope_workspace_id == workflow_workspace_id - - defp same_workspace?(_scope, _workflow), do: false - - defp can_read_workspace?(%__MODULE__{workspace_role: workspace_role}) - when workspace_role in @workspace_view_roles, - do: true - - defp can_read_workspace?(%__MODULE__{} = scope), do: organization_admin?(scope) - - defp can_write_workspace?(%__MODULE__{workspace_role: workspace_role}) - when workspace_role in @workspace_edit_roles, - do: true - - defp can_write_workspace?(%__MODULE__{} = scope), do: organization_admin?(scope) end diff --git a/lib/fizz/steps/executors/workflow_output.ex b/lib/fizz/steps/executors/workflow_output.ex deleted file mode 100644 index 713dfb7..0000000 --- a/lib/fizz/steps/executors/workflow_output.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule Fizz.Steps.Executors.WorkflowOutput do - use Fizz.Steps.Definition, - id: "workflow_output", - name: "Workflow Output", - category: "Output", - description: "Declares the workflow's output with an optional schema", - icon: "hero-arrow-right-end-on-rectangle", - kind: :action - - require Logger - - @config_schema %{ - "type" => "object", - "properties" => %{ - "name" => %{ - "type" => "string", - "title" => "Output Name", - "default" => "output", - "description" => "Identifier for this output (useful for multiple outputs)" - }, - "schema" => %{ - "type" => "object", - "title" => "Output Schema", - "description" => "JSON Schema describing the output shape" - }, - "value" => %{ - "title" => "Value", - "description" => "The data to output. Defaults to the step's input if not specified." - } - } - } - - @default_config %{"name" => "output"} - - @behaviour Fizz.Steps.Executors.Behaviour - - @impl true - def execute(config, input, _context) do - output = - case Map.fetch(config, "value") do - {:ok, value} -> value - :error -> input - end - - case validate_output(output, Map.get(config, "schema")) do - :ok -> - {:ok, output} - - {:error, errors} -> - Logger.warning("Workflow output does not match declared schema", errors: errors) - {:ok, output} - end - end - - defp validate_output(_output, nil), do: :ok - - defp validate_output(output, schema) do - case JSV.build(schema) do - {:ok, compiled} -> - case JSV.validate(output, compiled) do - {:ok, _} -> - :ok - - {:error, error} -> - {:error, JSV.normalize_error(error)} - end - - {:error, error} -> - {:error, %{message: Exception.message(error)}} - end - end -end diff --git a/lib/fizz/steps/registry.ex b/lib/fizz/steps/registry.ex index 0a1088a..4dffaee 100644 --- a/lib/fizz/steps/registry.ex +++ b/lib/fizz/steps/registry.ex @@ -245,7 +245,6 @@ defmodule Fizz.Steps.Registry do Fizz.Steps.Executors.DataFilter, Fizz.Steps.Executors.DataTransform, Fizz.Steps.Executors.DataOutput, - Fizz.Steps.Executors.WorkflowOutput, Fizz.Steps.Executors.Condition, Fizz.Steps.Executors.Switch, Fizz.Steps.Executors.Format, diff --git a/lib/fizz/workflows.ex b/lib/fizz/workflows.ex deleted file mode 100644 index defa239..0000000 --- a/lib/fizz/workflows.ex +++ /dev/null @@ -1,623 +0,0 @@ -defmodule Fizz.Workflows do - @moduledoc """ - Context for managing workflows, versions, drafts, and related functionality. - - All functions that require authorization accept a `Scope` as the first argument - following Phoenix conventions. Permission checks are performed through the - `Fizz.Accounts.Scope` module. - - ## Authorization - - - Use `Scope.can_view_workflow?/2` to check view permissions - - Use `Scope.can_edit_workflow?/2` to check edit permissions - - Workflow access is scoped to the active workspace in `Scope` - - ## Examples - - # List workflows accessible to the user - Workflows.list_workflows(scope) - - # Get a workflow (returns error if not accessible) - {:ok, workflow} = Workflows.get_workflow(scope, workflow_id) - - # Update a workflow (requires edit permission) - {:ok, workflow} = Workflows.update_workflow(scope, workflow, attrs) - """ - - import Ecto.Query, warn: false - alias Fizz.Repo - - alias Fizz.Workflows.{Workflow, WorkflowVersion, WorkflowDraft} - alias Fizz.Accounts.Scope - - @type workflow_params :: %{ - required(:name) => String.t(), - optional(:description) => String.t(), - optional(:current_version_tag) => String.t() - } - - @type workflow_version_params :: %{ - required(:version_tag) => String.t(), - optional(:changelog) => String.t() - } - - @doc """ - Lists workflows accessible to the given scope. - """ - @spec list_workflows(Scope.t() | nil) :: [Workflow.t()] - def list_workflows(%Scope{workspace: %{id: workspace_id}} = scope) - when is_binary(workspace_id) do - case Scope.can_view_workflow?(scope, %Workflow{workspace_id: workspace_id}) do - true -> - query = - from w in Workflow, - where: w.workspace_id == ^workspace_id, - order_by: [desc: w.updated_at] - - Repo.all(query) |> Repo.preload([:user, :workspace]) - - false -> - [] - end - end - - def list_workflows(_scope), do: [] - - @doc false - @spec list_public_workflows() :: [Workflow.t()] - def list_public_workflows, do: [] - - @doc """ - Determines the access level/state for a workflow relative to a scope. - - Returns: - - `:admin` for workspace admin (or organization admin) write access - - `:member` for workspace member write access - - `:viewer` for workspace viewer read-only access - - `nil` for no access - """ - @spec workflow_access_state(Scope.t() | nil, Workflow.t()) :: - :admin | :member | :viewer | nil - def workflow_access_state(%Scope{} = scope, %Workflow{} = workflow) do - cond do - Scope.can_edit_workflow?(scope, workflow) and - (Scope.workspace_admin?(scope) or Scope.organization_admin?(scope)) -> - :admin - - Scope.can_edit_workflow?(scope, workflow) -> - :member - - Scope.can_view_workflow?(scope, workflow) -> - :viewer - - true -> - nil - end - end - - def workflow_access_state(_scope, _workflow), do: nil - - @doc """ - Lists workflows owned by the user in the scope. - """ - @spec list_owned_workflows(Scope.t()) :: [Workflow.t()] - def list_owned_workflows(%Scope{workspace: %{id: workspace_id}, user: %{id: user_id}} = scope) - when is_binary(workspace_id) do - case Scope.can_view_workflow?(scope, %Workflow{workspace_id: workspace_id}) do - true -> - Repo.all( - from w in Workflow, - where: w.workspace_id == ^workspace_id and w.user_id == ^user_id, - order_by: [desc: w.updated_at] - ) - - false -> - [] - end - end - - def list_owned_workflows(%Scope{}), do: [] - - @doc """ - Returns a query for active workflows. - """ - def list_active_workflows_query do - from w in Workflow, where: w.status == :active - end - - # ============================================================================ - - @doc """ - Gets a single workflow by ID, checking access permissions. - - Returns `{:ok, workflow}` if the user has access, `{:error, :not_found}` otherwise. - """ - @spec get_workflow(Scope.t() | nil, String.t() | Ecto.UUID.t()) :: - {:ok, Workflow.t()} | {:error, :not_found} - def get_workflow(scope, id), do: do_get_workflow(id, scope) - - defp do_get_workflow(id, scope) do - case Repo.get(Workflow, id) do - nil -> - {:error, :not_found} - - %Workflow{} = workflow -> - if Scope.can_view_workflow?(scope, workflow) do - {:ok, workflow} - else - {:error, :not_found} - end - end - end - - @doc """ - Gets a workflow with its draft preloaded. - - Returns `{:ok, workflow}` with draft loaded, or `{:error, :not_found}`. - """ - @spec get_workflow_with_draft(Scope.t() | nil, String.t() | Ecto.UUID.t()) :: - {:ok, Workflow.t()} | {:error, :not_found} - def get_workflow_with_draft(scope, id), do: do_get_workflow_with_draft(id, scope) - - defp do_get_workflow_with_draft(id, scope) do - case Repo.get(Workflow, id) |> Repo.preload([:draft, :workspace]) do - nil -> - {:error, :not_found} - - %Workflow{} = workflow -> - if Scope.can_view_workflow?(scope, workflow) do - {:ok, %{workflow | draft: ensure_draft_defaults(workflow.draft)}} - else - {:error, :not_found} - end - end - end - - # ============================================================================ - # Create/Update/Delete Functions - # ============================================================================ - - @doc """ - Creates a new workflow for the user in the scope. - - Requires member/admin workflow permissions in the active workspace. - """ - @spec create_workflow(Scope.t(), workflow_params()) :: - {:ok, Workflow.t()} | {:error, Ecto.Changeset.t() | :access_denied} - def create_workflow( - %Scope{workspace: %{id: workspace_id}, user: %{id: user_id}} = scope, - attrs - ) - when is_binary(workspace_id) do - seed_workflow = %Workflow{workspace_id: workspace_id} - - case Scope.can_edit_workflow?(scope, seed_workflow) do - true -> - attrs - |> Map.put(:workspace_id, workspace_id) - |> Map.put(:user_id, user_id) - |> then(fn workflow_attrs -> - %Workflow{} - |> Workflow.create_changeset(workflow_attrs) - |> Repo.insert() - end) - - false -> - {:error, :access_denied} - end - end - - def create_workflow(%Scope{}, _attrs), do: {:error, :access_denied} - - @doc """ - Updates a workflow, checking edit permissions. - - Returns `{:ok, workflow}` if successful, `{:error, changeset | :access_denied}` otherwise. - """ - @spec update_workflow(Scope.t(), Workflow.t(), workflow_params()) :: - {:ok, Workflow.t()} | {:error, Ecto.Changeset.t() | :access_denied} - def update_workflow(%Scope{} = scope, %Workflow{} = workflow, attrs) do - if Scope.can_edit_workflow?(scope, workflow) do - workflow - |> Workflow.update_changeset(attrs) - |> Repo.update() - else - {:error, :access_denied} - end - end - - @doc """ - Deletes a workflow, checking edit permissions. - """ - @spec delete_workflow(Scope.t(), Workflow.t()) :: - {:ok, Workflow.t()} | {:error, :access_denied} - def delete_workflow(%Scope{} = scope, %Workflow{} = workflow) do - if Scope.can_edit_workflow?(scope, workflow) do - Repo.delete(workflow) - else - {:error, :access_denied} - end - end - - @doc """ - Archives a workflow, checking edit permissions. - """ - @spec archive_workflow(Scope.t(), Workflow.t()) :: - {:ok, Workflow.t()} | {:error, Ecto.Changeset.t() | :access_denied} - def archive_workflow(%Scope{} = scope, %Workflow{} = workflow) do - update_workflow(scope, workflow, %{status: :archived}) - end - - @doc """ - Duplicates a workflow for the current user. - - Returns `{:ok, workflow}` if successful, `{:error, reason}` otherwise. - """ - @spec duplicate_workflow(Scope.t(), Workflow.t()) :: - {:ok, Workflow.t()} | {:error, :access_denied | term()} - def duplicate_workflow(%Scope{} = scope, %Workflow{} = workflow) do - if Scope.can_view_workflow?(scope, workflow) do - Repo.transaction(fn -> - workflow = Repo.preload(workflow, :draft) - - draft = - workflow.draft || - %WorkflowDraft{steps: [], connections: [], groups: [], settings: %{}} - - workflow_attrs = %{ - name: "Copy of #{workflow.name}", - description: workflow.description, - status: :draft, - current_version_tag: nil, - published_version_id: nil - } - - with {:ok, duplicated} <- create_workflow(scope, workflow_attrs), - {:ok, _duplicated_draft} <- insert_duplicate_draft(duplicated.id, draft) do - duplicated - else - {:error, reason} -> Repo.rollback(reason) - end - end) - |> case do - {:ok, duplicated} -> {:ok, duplicated} - {:error, reason} -> {:error, reason} - end - else - {:error, :access_denied} - end - end - - # ============================================================================ - # Publishing Functions - # ============================================================================ - - @doc """ - Publishes a workflow version, creating a new published version. - - Returns `{:ok, {workflow, version}}` if successful, `{:error, reason}` otherwise. - """ - @spec publish_workflow(Scope.t(), Workflow.t(), workflow_version_params()) :: - {:ok, {Workflow.t(), WorkflowVersion.t()}} | {:error, any()} - def publish_workflow(%Scope{} = scope, %Workflow{} = workflow, version_attrs) do - if Scope.can_edit_workflow?(scope, workflow) do - Repo.transaction(fn -> - case Repo.get_by(WorkflowDraft, workflow_id: workflow.id) do - nil -> - Repo.rollback(:draft_not_found) - - draft -> - case Fizz.Workflows.Validator.validate(draft) do - :ok -> - version_attrs = - version_attrs - |> Map.put(:workflow_id, workflow.id) - |> Map.put( - :source_hash, - WorkflowVersion.compute_source_hash( - List.wrap(draft.steps), - List.wrap(draft.connections), - List.wrap(draft.groups) - ) - ) - |> Map.put(:steps, Enum.map(List.wrap(draft.steps), &Map.from_struct/1)) - |> Map.put( - :connections, - Enum.map(List.wrap(draft.connections), &Map.from_struct/1) - ) - |> Map.put(:groups, Enum.map(List.wrap(draft.groups), &Map.from_struct/1)) - |> Map.put(:published_by, scope.user.id) - - with {:ok, version} <- - %WorkflowVersion{} - |> WorkflowVersion.changeset(version_attrs) - |> Repo.insert(), - {:ok, updated_workflow} <- - workflow - |> Workflow.update_changeset(%{ - published_version_id: version.id, - status: :active, - current_version_tag: version.version_tag - }) - |> Repo.update() do - {updated_workflow, version} - else - {:error, reason} -> Repo.rollback(reason) - end - - {:error, errors} -> - Repo.rollback({:invalid_workflow, errors}) - end - end - end) - |> case do - {:ok, {updated_workflow, version}} -> - {:ok, {updated_workflow, version}} - - error -> - error - end - else - {:error, :access_denied} - end - end - - # ============================================================================ - # Version Functions - # ============================================================================ - - @doc """ - Gets a workflow version by ID, checking access permissions. - - Returns `{:ok, version}` if the user has access, `{:error, :not_found}` otherwise. - """ - @spec get_workflow_version(Scope.t() | nil, String.t() | Ecto.UUID.t()) :: - {:ok, WorkflowVersion.t()} | {:error, :not_found} - def get_workflow_version(scope, id) do - case Repo.get(WorkflowVersion, id) |> Repo.preload(:workflow) do - nil -> - {:error, :not_found} - - %WorkflowVersion{workflow: workflow} = version -> - if Scope.can_view_workflow?(scope, workflow) do - {:ok, version} - else - {:error, :not_found} - end - end - end - - @doc """ - Lists versions for a workflow, checking access permissions. - - Returns a list of versions if the user has access, empty list otherwise. - """ - @spec list_workflow_versions(Scope.t() | nil, Workflow.t()) :: [WorkflowVersion.t()] - def list_workflow_versions(scope, %Workflow{} = workflow) do - if Scope.can_view_workflow?(scope, workflow) do - Repo.all( - from v in WorkflowVersion, - where: v.workflow_id == ^workflow.id, - order_by: [desc: v.published_at] - ) - else - [] - end - end - - # ============================================================================ - # Draft Functions - # ============================================================================ - - @doc """ - Gets a workflow draft by workflow ID. - - Returns `{:ok, draft}` if found, `{:error, :not_found}` otherwise. - """ - @spec get_draft(Scope.t() | nil, String.t() | Ecto.UUID.t()) :: - {:ok, WorkflowDraft.t()} | {:error, :not_found} - def get_draft(scope, workflow_id) do - with {:ok, workflow} <- get_workflow(scope, workflow_id) do - case Repo.get_by(WorkflowDraft, workflow_id: workflow.id) do - nil -> {:error, :not_found} - draft -> {:ok, ensure_draft_defaults(draft)} - end - end - end - - @doc """ - Updates a workflow draft, checking edit permissions. - - Returns `{:ok, draft}` if successful, `{:error, changeset | :access_denied}` otherwise. - """ - @spec update_workflow_draft(Scope.t(), Workflow.t(), map()) :: - {:ok, WorkflowDraft.t()} | {:error, Ecto.Changeset.t() | :access_denied} - def update_workflow_draft(%Scope{} = scope, %Workflow{} = workflow, attrs) do - if Scope.can_edit_workflow?(scope, workflow) do - case Repo.get_by(WorkflowDraft, workflow_id: workflow.id) do - nil -> - # Create draft if it doesn't exist - %WorkflowDraft{} - |> WorkflowDraft.changeset(Map.put(attrs, :workflow_id, workflow.id)) - |> Repo.insert() - - draft -> - draft - |> WorkflowDraft.changeset(attrs) - |> Repo.update() - end - else - {:error, :access_denied} - end - end - - # ============================================================================ - # Trigger Functions - # ============================================================================ - - @trigger_type_ids ["schedule_trigger", "manual_input", "event_trigger"] - - @doc "Returns all trigger steps for the workflow." - @spec triggers(Workflow.t()) :: [map()] - def triggers(%Workflow{} = workflow) do - workflow = Repo.preload(workflow, :draft) - - case workflow.draft do - nil -> [] - draft -> Enum.filter(draft.steps || [], &is_trigger_step?/1) - end - end - - @doc "Returns all trigger steps of a specific type." - @spec triggers_of_type(Workflow.t(), atom() | String.t()) :: [map()] - def triggers_of_type(%Workflow{} = workflow, type) do - workflow = Repo.preload(workflow, :draft) - trigger_type_id = trigger_type_to_step_type_id(type) - - case workflow.draft do - nil -> [] - draft -> Enum.filter(draft.steps || [], &(&1.type_id == trigger_type_id)) - end - end - - @doc "Checks if the workflow has at least one trigger of a specific type." - @spec has_trigger_type?(Workflow.t(), atom() | String.t()) :: boolean() - def has_trigger_type?(%Workflow{} = workflow, type) do - workflow = Repo.preload(workflow, :draft) - trigger_type_id = trigger_type_to_step_type_id(type) - - case workflow.draft do - nil -> false - draft -> Enum.any?(draft.steps || [], &(&1.type_id == trigger_type_id)) - end - end - - @doc "Returns a count of triggers grouped by type." - @spec trigger_counts(Workflow.t()) :: %{String.t() => non_neg_integer()} - def trigger_counts(%Workflow{} = workflow) do - workflow - |> triggers() - |> Enum.group_by(& &1.type_id) - |> Map.new(fn {type_id, steps} -> {type_id, length(steps)} end) - end - - defp is_trigger_step?(%{type_id: type_id}) do - type_id in @trigger_type_ids - end - - defp trigger_type_to_step_type_id(:schedule), do: "schedule_trigger" - defp trigger_type_to_step_type_id(:manual), do: "manual_input" - defp trigger_type_to_step_type_id(:event), do: "event_trigger" - defp trigger_type_to_step_type_id(type) when is_binary(type), do: type - - # ============================================================================ - # Step Identity - # ============================================================================ - - @doc """ - Converts a display name into a key-safe step ID. - - Uses underscores (not hyphens) to create identifiers safe for use as: - - Expression variable namespaces (`steps..json`) - - Runic component names - - Connection references - - ## Examples - - iex> to_step_id("HTTP Request") - "http_request" - - iex> to_step_id("My Step 1") - "my_step_1" - """ - @spec to_step_id(String.t()) :: String.t() - def to_step_id(name) when is_binary(name) do - name - |> String.downcase() - # Use the `u` flag so the regex treats full Unicode codepoints (not raw bytes). - # Without `u`, multi-byte chars like — (U+2014, 0xE2 0x80 0x94) are matched only - # by their first byte, leaving orphaned bytes that corrupt the resulting string. - |> String.replace(~r/[^\w\s]/u, "") - |> String.replace(~r/[\s]+/u, "_") - |> String.replace(~r/_+/, "_") - |> String.trim("_") - end - - def to_step_id(_), do: "" - - @doc """ - Generates a unique step name and ID pair for a workflow. - - Returns `{name, step_id}` where both are unique within the workflow. - Handles "Name", "Name 2", "Name 3" etc. for display names, - and "name", "name_2", "name_3" for step IDs. - - ## Examples - - iex> generate_unique_step_identity([], "HTTP Request") - {"HTTP Request", "http_request"} - - iex> generate_unique_step_identity([%{name: "HTTP Request", id: "http_request"}], "HTTP Request") - {"HTTP Request 2", "http_request_2"} - """ - @spec generate_unique_step_identity([map()], String.t()) :: {String.t(), String.t()} - def generate_unique_step_identity(existing_steps, base_name) do - existing_names = - existing_steps - |> Enum.map(&(Map.get(&1, :name) || Map.get(&1, "name"))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - existing_ids = - existing_steps - |> Enum.map(&(Map.get(&1, :id) || Map.get(&1, "id"))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - do_generate_unique_identity(existing_names, existing_ids, base_name, 1) - end - - defp do_generate_unique_identity(existing_names, existing_ids, base_name, index) do - candidate_name = if index == 1, do: base_name, else: "#{base_name} #{index}" - candidate_id = to_step_id(candidate_name) - - cond do - MapSet.member?(existing_names, candidate_name) -> - do_generate_unique_identity(existing_names, existing_ids, base_name, index + 1) - - MapSet.member?(existing_ids, candidate_id) -> - do_generate_unique_identity(existing_names, existing_ids, base_name, index + 1) - - true -> - {candidate_name, candidate_id} - end - end - - # ============================================================================ - # Private Helpers - # ============================================================================ - - defp insert_duplicate_draft(workflow_id, draft) do - draft_attrs = %{ - steps: Enum.map(List.wrap(draft.steps), &Map.from_struct/1), - connections: Enum.map(List.wrap(draft.connections), &Map.from_struct/1), - groups: Enum.map(List.wrap(draft.groups), &Map.from_struct/1), - settings: draft.settings || %{} - } - - %WorkflowDraft{workflow_id: workflow_id} - |> WorkflowDraft.changeset(draft_attrs) - |> Repo.insert() - end - - defp ensure_draft_defaults(nil), do: nil - - defp ensure_draft_defaults(draft) do - %{ - draft - | steps: draft.steps || [], - connections: draft.connections || [], - groups: draft.groups || [] - } - end -end diff --git a/lib/fizz/workflows/embeds/connection.ex b/lib/fizz/workflows/embeds/connection.ex deleted file mode 100644 index b915a73..0000000 --- a/lib/fizz/workflows/embeds/connection.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule Fizz.Workflows.Embeds.Connection do - @moduledoc """ - Embedded schema for workflow connections (edges between steps). - Shared between Workflow (mutable) and WorkflowVersion (immutable). - """ - @derive Jason.Encoder - @derive {LiveVue.Encoder, - only: [ - :id, - :source_step_id, - :source_output, - :target_step_id, - :target_input - ]} - - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:id, :string, autogenerate: false} - - @type t :: %__MODULE__{ - id: String.t(), - source_step_id: String.t(), - source_output: String.t(), - target_step_id: String.t(), - target_input: String.t() - } - - embedded_schema do - field :source_step_id, :string - field :source_output, :string, default: "main" - field :target_step_id, :string - field :target_input, :string, default: "main" - end - - def changeset(connection, attrs) do - connection - |> cast(attrs, [:id, :source_step_id, :source_output, :target_step_id, :target_input]) - |> validate_required([:id, :source_step_id, :target_step_id]) - end -end diff --git a/lib/fizz/workflows/embeds/node_group.ex b/lib/fizz/workflows/embeds/node_group.ex deleted file mode 100644 index 2f892c5..0000000 --- a/lib/fizz/workflows/embeds/node_group.ex +++ /dev/null @@ -1,78 +0,0 @@ -defmodule Fizz.Workflows.Embeds.NodeGroup do - @moduledoc """ - Embedded schema for workflow node groups. - - A node group is a visual and execution boundary that exposes a single output. - """ - @derive Jason.Encoder - @derive {LiveVue.Encoder, - only: [ - :id, - :name, - :step_ids, - :output_step_id, - :position, - :color, - :font_size, - :collapsed - ]} - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:id, :string, autogenerate: false} - @default_font_size 14 - @min_font_size 10 - @max_font_size 32 - - @type t :: %__MODULE__{ - id: String.t(), - name: String.t(), - step_ids: [String.t()], - output_step_id: String.t(), - position: map(), - color: String.t() | nil, - font_size: integer(), - collapsed: boolean() - } - - embedded_schema do - field :name, :string - field :step_ids, {:array, :string}, default: [] - field :output_step_id, :string - field :position, :map, default: %{} - field :color, :string - field :font_size, :integer, default: @default_font_size - field :collapsed, :boolean, default: false - end - - def changeset(group, attrs) do - group - |> cast(attrs, [ - :id, - :name, - :step_ids, - :output_step_id, - :position, - :color, - :font_size, - :collapsed - ]) - |> validate_required([:id, :name, :step_ids, :output_step_id]) - |> validate_number(:font_size, - greater_than_or_equal_to: @min_font_size, - less_than_or_equal_to: @max_font_size - ) - |> validate_output_step_in_group() - end - - defp validate_output_step_in_group(changeset) do - output_step_id = get_field(changeset, :output_step_id) - step_ids = get_field(changeset, :step_ids) || [] - - if output_step_id && output_step_id not in step_ids do - add_error(changeset, :output_step_id, "must be one of the group's steps") - else - changeset - end - end -end diff --git a/lib/fizz/workflows/embeds/step.ex b/lib/fizz/workflows/embeds/step.ex deleted file mode 100644 index a01e55c..0000000 --- a/lib/fizz/workflows/embeds/step.ex +++ /dev/null @@ -1,60 +0,0 @@ -defmodule Fizz.Workflows.Embeds.Step do - @moduledoc """ - Embedded schema for workflow steps. - Shared between Workflow (mutable) and WorkflowVersion (immutable). - - ## Identity - - - `id` - Key-safe slug derived from display name (e.g., "http_request", "my_step_1") - - `name` - User-facing display label (e.g., "HTTP Request", "My Step 1") - - `type_id` - Reference to global step type registry - - The `id` is used everywhere as the primary instance identifier: - - Runic component names - - Expression variable keys (`steps..json`) - - Connection references - """ - @derive Jason.Encoder - @derive {LiveVue.Encoder, - only: [ - :id, - :type_id, - :name, - :config, - :position, - :notes - ]} - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:id, :string, autogenerate: false} - - # Key-safe step ID pattern: lowercase alphanumeric + underscores - @step_id_pattern ~r/^[a-z][a-z0-9_]*$/ - - @type t :: %__MODULE__{ - id: String.t(), - type_id: String.t(), - name: String.t(), - config: map(), - position: map(), - notes: String.t() | nil - } - - embedded_schema do - field :type_id, :string - field :name, :string - field :config, :map, default: %{} - field :position, :map, default: %{} - field :notes, :string - end - - def changeset(step, attrs) do - step - |> cast(attrs, [:id, :type_id, :name, :config, :position, :notes]) - |> validate_required([:id, :type_id, :name]) - |> validate_format(:id, @step_id_pattern, - message: "must be a key-safe identifier (lowercase, alphanumeric, underscores)" - ) - end -end diff --git a/lib/fizz/workflows/validator.ex b/lib/fizz/workflows/validator.ex deleted file mode 100644 index 9965fae..0000000 --- a/lib/fizz/workflows/validator.ex +++ /dev/null @@ -1,443 +0,0 @@ -defmodule Fizz.Workflows.Validator do - @moduledoc """ - Validates workflow draft integrity, including node group boundaries. - """ - - alias Fizz.Steps.Executors.Behaviour, as: StepExecutorBehaviour - alias Fizz.Steps.Registry, as: StepRegistry - alias Fizz.Workflows.WorkflowDraft - - @spec validate(WorkflowDraft.t()) :: :ok | {:error, list()} - def validate(%WorkflowDraft{} = draft) do - errors = - [] - |> Kernel.++(validate_group_integrity(draft)) - |> Kernel.++(validate_group_connectivity(draft)) - |> Kernel.++(validate_group_connections(draft)) - |> Kernel.++(validate_no_cross_group_references(draft)) - |> Kernel.++(validate_subnode_connections(draft)) - |> Kernel.++(validate_auth_step_configs(draft)) - - if errors == [] do - :ok - else - {:error, errors} - end - end - - defp validate_group_integrity(draft) do - groups = draft.groups || [] - steps = draft.steps || [] - step_ids = MapSet.new(Enum.map(steps, & &1.id)) - - grouped_step_ids = Enum.flat_map(groups, & &1.step_ids) - - duplicate_step_ids = - grouped_step_ids - |> Enum.frequencies() - |> Enum.filter(fn {_id, count} -> count > 1 end) - |> Enum.map(fn {id, _count} -> id end) - - missing_step_ids = - grouped_step_ids - |> Enum.reject(&MapSet.member?(step_ids, &1)) - - errors = [] - - errors = - if duplicate_step_ids == [] do - errors - else - [{:groups, "Steps #{inspect(duplicate_step_ids)} belong to multiple groups"} | errors] - end - - errors = - if missing_step_ids == [] do - errors - else - [{:groups, "Groups reference missing steps #{inspect(missing_step_ids)}"} | errors] - end - - output_errors = - Enum.flat_map(groups, fn group -> - if group.output_step_id in (group.step_ids || []) do - [] - else - [{:group, group.id, "output_step_id must be one of the group's steps"}] - end - end) - - errors ++ output_errors - end - - defp validate_group_connectivity(draft) do - groups = draft.groups || [] - - connections = - draft.connections - |> List.wrap() - |> Enum.filter(&main_connection?/1) - - Enum.flat_map(groups, fn group -> - entry_step_id = group_entry_step(group, connections) - - case entry_step_id do - nil -> - [{:group, group.id, "must have exactly one entry step"}] - - entry_step_id -> - reachable = group_reachable_steps(entry_step_id, group, connections) - missing = MapSet.difference(MapSet.new(group.step_ids), reachable) - - if MapSet.size(missing) == 0 do - [] - else - [ - {:group, group.id, - "contains disconnected steps #{inspect(MapSet.to_list(missing))}"} - ] - end - end - end) - end - - defp validate_group_connections(draft) do - groups = draft.groups || [] - - connections = - draft.connections - |> List.wrap() - |> Enum.filter(&main_connection?/1) - - Enum.flat_map(groups, fn group -> - entry_step_id = group_entry_step(group, connections) - - external_incoming = - Enum.filter(connections, fn conn -> - conn.target_step_id in group.step_ids and conn.source_step_id not in group.step_ids - end) - - incoming_errors = - if entry_step_id do - Enum.flat_map(external_incoming, fn conn -> - if conn.target_step_id == entry_step_id do - [] - else - [ - {:group, group.id, "external connections must target entry step #{entry_step_id}"} - ] - end - end) - else - [] - end - - outgoing_errors = - connections - |> Enum.filter(fn conn -> - conn.source_step_id in group.step_ids and conn.target_step_id not in group.step_ids - end) - |> Enum.flat_map(fn conn -> - if conn.source_step_id == group.output_step_id do - [] - else - [ - {:group, group.id, "only output_step_id can connect outside the group"} - ] - end - end) - - incoming_errors ++ outgoing_errors - end) - end - - defp validate_no_cross_group_references(draft) do - groups = draft.groups || [] - steps = draft.steps || [] - - step_to_group = - groups - |> Enum.flat_map(fn group -> Enum.map(group.step_ids, &{&1, group.id}) end) - |> Map.new() - - Enum.flat_map(steps, fn step -> - step_group = Map.get(step_to_group, step.id) - referenced_steps = extract_step_references(step.config) - - Enum.flat_map(referenced_steps, fn ref_step_id -> - ref_group = Map.get(step_to_group, ref_step_id) - - if step_group == ref_group do - [] - else - [ - {:step, step.id, "cannot reference #{ref_step_id} across group boundaries"} - ] - end - end) - end) - end - - defp validate_subnode_connections(draft) do - steps = draft.steps || [] - connections = draft.connections || [] - steps_by_id = Map.new(steps, &{&1.id, &1}) - - connection_errors = - Enum.flat_map(connections, fn conn -> - target_input = connection_field(conn, :target_input) - - if main_target_input?(target_input) do - [] - else - validate_subnode_connection(conn, steps_by_id) - end - end) - - required_and_cardinality_errors = - Enum.flat_map(steps, fn step -> - validate_step_slot_requirements(step, connections) - end) - - connection_errors ++ required_and_cardinality_errors - end - - defp validate_subnode_connection(conn, steps_by_id) do - source_step_id = connection_field(conn, :source_step_id) - target_step_id = connection_field(conn, :target_step_id) - slot_id = normalize_target_input(connection_field(conn, :target_input)) - - with {:ok, source_step} <- fetch_step(steps_by_id, source_step_id), - {:ok, target_step} <- fetch_step(steps_by_id, target_step_id), - {:ok, target_type} <- StepRegistry.get(target_step.type_id), - {:ok, slot_def} <- fetch_slot_def(target_type, slot_id), - :ok <- validate_slot_accepts_source(slot_def, source_step.type_id) do - [] - else - {:error, {:step_not_found, step_id}} -> - [{:connection, connection_field(conn, :id), "references missing step #{step_id}"}] - - {:error, {:unknown_target_type, type_id}} -> - [ - {:connection, connection_field(conn, :id), - "target step type #{type_id} is not registered"} - ] - - {:error, {:unknown_slot, target_type_id, unknown_slot_id}} -> - [ - {:connection, connection_field(conn, :id), - "slot #{unknown_slot_id} is not defined on step type #{target_type_id}"} - ] - - {:error, {:disallowed_source_type, source_type_id, slot_id_value}} -> - [ - {:connection, connection_field(conn, :id), - "step type #{source_type_id} is not allowed for slot #{slot_id_value}"} - ] - end - end - - defp validate_step_slot_requirements(step, connections) do - case StepRegistry.get(step.type_id) do - {:ok, target_type} -> - target_type - |> slot_defs() - |> Enum.flat_map(fn slot_def -> - slot_id = slot_field(slot_def, :id) - slot_connections = slot_connections_for_step(connections, step.id, slot_id) - required? = slot_field(slot_def, :required) == true - cardinality = slot_field(slot_def, :cardinality) || "one" - slot_errors = [] - - slot_errors = - if required? and slot_connections == [] do - [{:step, step.id, "required slot #{slot_id} must have at least one connection"}] - else - slot_errors - end - - if cardinality == "one" and length(slot_connections) > 1 do - [ - {:step, step.id, "slot #{slot_id} allows only one sub-node connection"} - | slot_errors - ] - else - slot_errors - end - end) - - {:error, :not_found} -> - [{:step, step.id, "step type #{step.type_id} is not registered"}] - end - end - - defp fetch_step(steps_by_id, step_id) when is_binary(step_id) do - case Map.get(steps_by_id, step_id) do - nil -> {:error, {:step_not_found, step_id}} - step -> {:ok, step} - end - end - - defp fetch_slot_def(target_type, slot_id) when is_binary(slot_id) do - case Enum.find(slot_defs(target_type), fn slot_def -> slot_field(slot_def, :id) == slot_id end) do - nil -> {:error, {:unknown_slot, target_type.id, slot_id}} - slot_def -> {:ok, slot_def} - end - end - - defp validate_slot_accepts_source(slot_def, source_type_id) do - accepted_type_ids = slot_accepts_type_ids(slot_def) - - if source_type_id in accepted_type_ids do - :ok - else - {:error, {:disallowed_source_type, source_type_id, slot_field(slot_def, :id)}} - end - end - - defp slot_connections_for_step(connections, step_id, slot_id) do - Enum.filter(connections, fn conn -> - connection_field(conn, :target_step_id) == step_id and - normalize_target_input(connection_field(conn, :target_input)) == slot_id - end) - end - - defp slot_defs(type) when is_map(type), - do: Map.get(type, :subnode_slots) || Map.get(type, "subnode_slots") || [] - - defp slot_accepts_type_ids(slot_def) do - slot_def - |> slot_field(:accepts) - |> case do - accepts when is_map(accepts) -> - Map.get(accepts, "type_ids") || Map.get(accepts, :type_ids) || [] - - _ -> - [] - end - end - - defp slot_field(slot_def, field) when is_map(slot_def) and is_atom(field) do - Map.get(slot_def, field) || Map.get(slot_def, Atom.to_string(field)) - end - - defp connection_field(conn, field) when is_map(conn) and is_atom(field) do - Map.get(conn, field) || Map.get(conn, Atom.to_string(field)) - end - - defp normalize_target_input(target_input) when target_input in [nil, :main, "main", ""], - do: "main" - - defp normalize_target_input(target_input) when is_atom(target_input), - do: Atom.to_string(target_input) - - defp normalize_target_input(target_input) when is_binary(target_input), do: target_input - defp normalize_target_input(_target_input), do: "main" - - defp main_target_input?(target_input), do: normalize_target_input(target_input) == "main" - - defp main_connection?(connection) do - connection - |> connection_field(:target_input) - |> main_target_input?() - end - - defp group_entry_step(group, connections) do - internal_incoming = - connections - |> Enum.filter(fn conn -> - conn.target_step_id in group.step_ids and conn.source_step_id in group.step_ids - end) - |> Enum.group_by(& &1.target_step_id) - - entry_steps = - group.step_ids - |> Enum.filter(fn step_id -> Map.get(internal_incoming, step_id, []) == [] end) - - case entry_steps do - [entry_step_id] -> entry_step_id - _ -> nil - end - end - - defp group_reachable_steps(entry_step_id, group, connections) do - adjacency = - connections - |> Enum.filter(fn conn -> - conn.source_step_id in group.step_ids and conn.target_step_id in group.step_ids - end) - |> Enum.group_by(& &1.source_step_id, & &1.target_step_id) - - traverse_group([entry_step_id], adjacency, MapSet.new()) - end - - defp traverse_group([], _adjacency, visited), do: visited - - defp traverse_group([current | rest], adjacency, visited) do - if MapSet.member?(visited, current) do - traverse_group(rest, adjacency, visited) - else - visited = MapSet.put(visited, current) - children = Map.get(adjacency, current, []) - traverse_group(children ++ rest, adjacency, visited) - end - end - - defp extract_step_references(config) when is_map(config) do - config - |> Jason.encode!() - |> then(fn json -> - Regex.scan(~r/\{\{\s*steps\.([a-zA-Z0-9_]+)/, json) - |> Enum.map(fn [_, step_id] -> step_id end) - |> Enum.uniq() - end) - end - - defp extract_step_references(_), do: [] - - defp validate_auth_step_configs(draft) do - draft - |> Map.get(:steps, []) - |> List.wrap() - |> Enum.flat_map(fn step -> - case StepRegistry.get(step.type_id) do - {:ok, step_type} -> - if credential_ref_required?(step_type) do - case StepExecutorBehaviour.validate_config(step.type_id, step.config || %{}) do - :ok -> - [] - - {:error, errors} -> - Enum.map(errors, &step_config_error(step.id, &1)) - end - else - [] - end - - {:error, :not_found} -> - [] - end - end) - end - - defp credential_ref_required?(step_type) do - properties = - step_type - |> Map.get(:config_schema, %{}) - |> Map.get("properties", %{}) - - Map.has_key?(properties, "credential_ref") - end - - defp step_config_error(step_id, {field, message}) when is_atom(field) and is_binary(message) do - {:step, step_id, "#{field} #{message}"} - end - - defp step_config_error(step_id, {field, message}) - when is_binary(field) and is_binary(message) do - {:step, step_id, "#{field} #{message}"} - end - - defp step_config_error(step_id, message) when is_binary(message), do: {:step, step_id, message} - defp step_config_error(step_id, message), do: {:step, step_id, inspect(message)} -end diff --git a/lib/fizz/workflows/workflow.ex b/lib/fizz/workflows/workflow.ex deleted file mode 100644 index a2cb1c2..0000000 --- a/lib/fizz/workflows/workflow.ex +++ /dev/null @@ -1,97 +0,0 @@ -defmodule Fizz.Workflows.Workflow do - @moduledoc """ - Workflow schema. - """ - use Fizz.Schema - import Ecto.Changeset - alias Fizz.Accounts.Workspace - alias Fizz.Workflows.{WorkflowDraft, WorkflowVersion} - - defimpl LiveVue.Encoder, for: Ecto.Association.NotLoaded do - def encode(_struct, _opts), do: nil - end - - @derive {Jason.Encoder, - only: [ - :id, - :name, - :description, - :status, - :current_version_tag, - :published_version_id, - :workspace_id, - :user_id, - :inserted_at, - :updated_at - ]} - @derive {LiveVue.Encoder, - only: [ - :id, - :name, - :description, - :status, - :current_version_tag, - :published_version_id, - :workspace_id, - :user_id, - :inserted_at, - :updated_at, - :draft, - :workspace, - :user, - :published_version - ]} - - schema "workflows" do - field :name, :string - field :description, :string - field :status, Ecto.Enum, values: [:draft, :active, :archived], default: :draft - field :current_version_tag, :string - - belongs_to :published_version, WorkflowVersion - belongs_to :workspace, Workspace - belongs_to :user, Fizz.Accounts.User - has_one :draft, WorkflowDraft - has_many :versions, WorkflowVersion - - timestamps() - end - - @doc """ - Builds a changeset for creating a workflow. - """ - def create_changeset(workflow, attrs) do - workflow - |> cast(attrs, [ - :name, - :description, - :status, - :current_version_tag, - :published_version_id, - :workspace_id, - :user_id - ]) - |> validate_required([:name, :workspace_id, :user_id]) - |> foreign_key_constraint(:workspace_id) - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:published_version_id) - end - - @doc """ - Builds a changeset for updating a workflow. - - Ownership and workspace scope are immutable after creation. - """ - def update_changeset(workflow, attrs) do - workflow - |> cast(attrs, [ - :name, - :description, - :status, - :current_version_tag, - :published_version_id - ]) - |> validate_required([:name]) - |> foreign_key_constraint(:published_version_id) - end -end diff --git a/lib/fizz/workflows/workflow_draft.ex b/lib/fizz/workflows/workflow_draft.ex deleted file mode 100644 index 3b796bb..0000000 --- a/lib/fizz/workflows/workflow_draft.ex +++ /dev/null @@ -1,74 +0,0 @@ -defmodule Fizz.Workflows.WorkflowDraft do - @moduledoc """ - Private mutable draft state for a workflow. - """ - use Fizz.Schema - - alias Fizz.Workflows.Workflow - alias Fizz.Workflows.Embeds.{Step, Connection, NodeGroup} - - @type t :: %__MODULE__{ - workflow_id: Ecto.UUID.t(), - steps: [Step.t()] | nil, - connections: [Connection.t()] | nil, - groups: [NodeGroup.t()] | nil, - editor_state: map(), - settings: map(), - workflow: Workflow.t() | Ecto.Association.NotLoaded.t(), - inserted_at: DateTime.t(), - updated_at: DateTime.t() - } - - @derive {LiveVue.Encoder, - only: [ - :workflow_id, - :steps, - :connections, - :groups, - :settings, - :inserted_at, - :updated_at - ]} - @primary_key {:workflow_id, :binary_id, autogenerate: false} - schema "workflow_drafts" do - belongs_to :workflow, Workflow, define_field: false - - embeds_many :steps, Step, on_replace: :delete - embeds_many :connections, Connection, on_replace: :delete - embeds_many :groups, NodeGroup, on_replace: :delete - - field :editor_state, :map, default: %{} - - field :settings, :map, - default: %{ - timeout_ms: 300_000, - max_retries: 3 - } - - timestamps() - end - - def changeset(draft, attrs) do - draft - |> cast(attrs, [:workflow_id, :editor_state, :settings]) - |> cast_embed(:steps) - |> cast_embed(:connections) - |> cast_embed(:groups) - |> validate_required([:workflow_id]) - |> ensure_embed_defaults() - end - - defp ensure_embed_defaults(changeset) do - changeset - |> maybe_put_default_embed(:steps) - |> maybe_put_default_embed(:connections) - |> maybe_put_default_embed(:groups) - end - - defp maybe_put_default_embed(changeset, field) do - case get_field(changeset, field) do - nil -> put_change(changeset, field, []) - _ -> changeset - end - end -end diff --git a/lib/fizz/workflows/workflow_version.ex b/lib/fizz/workflows/workflow_version.ex deleted file mode 100644 index 650373c..0000000 --- a/lib/fizz/workflows/workflow_version.ex +++ /dev/null @@ -1,113 +0,0 @@ -defmodule Fizz.Workflows.WorkflowVersion do - @moduledoc """ - Immutable workflow version snapshot. - - Created each time a workflow is published, preserving the exact - definition for audit trails and execution reproducibility. - """ - @derive {Jason.Encoder, - except: [ - :__meta__, - :workflow, - :published_by_user - ]} - use Fizz.Schema - - alias Fizz.Workflows.Workflow - alias Fizz.Workflows.Embeds.{Step, Connection, NodeGroup} - alias Fizz.Accounts.User - - @type t :: %__MODULE__{ - id: Ecto.UUID.t(), - version_tag: String.t(), - source_hash: String.t(), - steps: [Step.t()], - connections: [Connection.t()], - groups: [NodeGroup.t()], - changelog: String.t() | nil, - published_at: DateTime.t() | nil, - published_by: Ecto.UUID.t() | nil, - workflow_id: Ecto.UUID.t(), - workflow: Workflow.t() | Ecto.Association.NotLoaded.t(), - published_by_user: User.t() | Ecto.Association.NotLoaded.t() | nil, - inserted_at: DateTime.t() - } - - schema "workflow_versions" do - # Human-friendly version tag can be semver or anything else - field :version_tag, :string - - # Content hash of steps + connections (SHA-256) - field :source_hash, :string - - embeds_many :steps, Step, on_replace: :delete - embeds_many :connections, Connection, on_replace: :delete - embeds_many :groups, NodeGroup, on_replace: :delete - - field :changelog, :string - - field :published_at, :utc_datetime_usec - belongs_to :published_by_user, User, foreign_key: :published_by - - belongs_to :workflow, Workflow - - # Immutable - no updates - timestamps(updated_at: false) - end - - def changeset(version, attrs) do - version - |> cast(attrs, [ - :version_tag, - :changelog, - :published_at, - :published_by, - :source_hash, - :workflow_id - ]) - |> cast_embed(:steps, required: true) - |> cast_embed(:connections) - |> cast_embed(:groups) - |> validate_required([:version_tag, :workflow_id, :source_hash]) - |> unique_constraint([:workflow_id, :version_tag]) - end - - @doc """ - Computes a content hash for the given steps and connections. - Used to detect changes between versions. - """ - def compute_source_hash(steps, connections, groups \\ []) do - content = - %{ - steps: normalize_for_hash(steps), - connections: normalize_for_hash(connections), - groups: normalize_for_hash(groups) - } - |> Jason.encode!() - - :crypto.hash(:sha256, content) - |> Base.encode16(case: :lower) - end - - defp normalize_for_hash(items) when is_list(items) do - items - |> Enum.map(fn - %Step{} = step -> - # Position is excluded as it doesn't affect behavior - Map.take(step, [:id, :type_id, :name, :config, :notes]) - - %Connection{} = conn -> - Map.take(conn, [:id, :source_step_id, :source_output, :target_step_id, :target_input]) - - %NodeGroup{} = group -> - Map.take(group, [:id, :name, :step_ids, :output_step_id, :color, :font_size, :collapsed]) - - item when is_map(item) -> - Map.drop(item, [:position, :__struct__, :__meta__]) - end) - |> Enum.sort_by(fn item -> - # Stable sort by ID, falling back to type or encoded content - Map.get(item, :id) || Map.get(item, :type) || Jason.encode!(item) - end) - end -end diff --git a/lib/fizz_web/formatters.ex b/lib/fizz_web/formatters.ex deleted file mode 100644 index b1b1e25..0000000 --- a/lib/fizz_web/formatters.ex +++ /dev/null @@ -1,103 +0,0 @@ -defmodule FizzWeb.Formatters do - @moduledoc """ - Shared formatting helpers for LiveViews and templates. - """ - - @doc """ - Formats a timestamp for display. - """ - def formatted_timestamp(nil), do: "-" - - def formatted_timestamp(datetime) do - Calendar.strftime(datetime, "%b %d, %Y at %H:%M") - end - - @doc """ - Returns a shortened ID for display. - """ - def short_id(nil), do: "-" - - def short_id(id) when is_binary(id) do - String.slice(id, 0, 8) - end - - @doc """ - Returns the CSS class for a workflow status badge. - """ - def status_badge_class(:draft), do: "badge-warning" - def status_badge_class(:active), do: "badge-success" - def status_badge_class(:archived), do: "badge-neutral" - def status_badge_class(_), do: "badge-ghost" - - @doc """ - Returns a human-readable label for a workflow status. - """ - def status_label(:draft), do: "Draft" - def status_label(:active), do: "Published" - def status_label(:archived), do: "Archived" - def status_label(status), do: to_string(status) - - @doc """ - Returns a human-readable label for the workflow trigger type. - """ - def trigger_label(%{trigger_config: %{"type" => type}}) do - trigger_type_label(type) - end - - def trigger_label(%{trigger_config: %{type: type}}) do - trigger_type_label(type) - end - - def trigger_label(_), do: "Manual" - - @doc """ - Returns the CSS class for an execution status badge. - """ - def execution_status_class(:completed), do: "badge-success" - def execution_status_class(:failed), do: "badge-error" - def execution_status_class(:running), do: "badge-info" - def execution_status_class(:pending), do: "badge-warning" - def execution_status_class(:paused), do: "badge-warning" - def execution_status_class(:cancelled), do: "badge-neutral" - def execution_status_class(:timeout), do: "badge-error" - def execution_status_class(_), do: "badge-ghost" - - @doc """ - Formats a duration in microseconds for display. - """ - def format_duration(nil), do: "-" - def format_duration(us) when us < 1000, do: "#{us}μs" - def format_duration(us) when us < 1_000_000, do: "#{Float.round(us / 1000, 2)}ms" - def format_duration(us), do: "#{Float.round(us / 1_000_000, 2)}s" - - @doc """ - Formats a datetime as relative time (e.g., "just now", "5m ago"). - """ - def format_relative_time(nil), do: "unknown time" - - def format_relative_time(datetime_str) when is_binary(datetime_str) do - case DateTime.from_iso8601(datetime_str) do - {:ok, dt, _} -> format_relative_time(dt) - _ -> datetime_str - end - end - - def format_relative_time(%DateTime{} = dt) do - diff = DateTime.diff(DateTime.utc_now(), dt, :second) - - cond do - diff < 60 -> "just now" - diff < 3600 -> "#{div(diff, 60)}m ago" - diff < 86400 -> "#{div(diff, 3600)}h ago" - true -> formatted_timestamp(dt) - end - end - - defp trigger_type_label("manual"), do: "Manual" - defp trigger_type_label(:manual), do: "Manual" - defp trigger_type_label("schedule"), do: "Scheduled" - defp trigger_type_label(:schedule), do: "Scheduled" - defp trigger_type_label("event"), do: "Event" - defp trigger_type_label(:event), do: "Event" - defp trigger_type_label(type), do: to_string(type) -end diff --git a/lib/fizz_web/live/workflow_live/index.ex b/lib/fizz_web/live/workflow_live/index.ex deleted file mode 100644 index 10bae24..0000000 --- a/lib/fizz_web/live/workflow_live/index.ex +++ /dev/null @@ -1,333 +0,0 @@ -defmodule FizzWeb.WorkflowLive.Index do - @moduledoc """ - LiveView for browsing workflows. - - Presents an index of workflows with ability to create new ones. - """ - use FizzWeb, :live_view - - alias Fizz.Accounts - alias Fizz.Workflows - alias FizzWeb.WorkflowLive.Paths - import FizzWeb.Formatters - - @impl true - def mount(%{"workspace_id" => workspace_id}, _session, socket) do - case Accounts.build_scope_for_workspace(socket.assigns.current_scope, workspace_id) do - {:ok, scope} -> - workflows = - scope - |> Workflows.list_workflows() - |> sort_workflows() - - {:ok, - socket - |> assign(:current_scope, scope) - |> assign(workflows_empty?: workflows == []) - |> stream(:workflows, workflows)} - - {:error, _reason} -> - {:ok, - socket - |> put_flash(:error, "Workspace not found") - |> redirect(to: ~p"/workspaces")} - end - end - - @impl true - def handle_event("open_workflow", %{"workflow_id" => workflow_id}, socket) do - {:noreply, - push_navigate(socket, - to: Paths.workflow_show_path(socket.assigns.current_scope, workflow_id) - )} - end - - @impl true - def handle_event("create_workflow", _params, socket) do - scope = socket.assigns.current_scope - default_name = "Untitled Workflow" - - case Workflows.create_workflow(scope, %{name: default_name}) do - {:ok, workflow} -> - {:noreply, - socket - |> put_flash(:info, "Workflow created") - |> push_navigate(to: Paths.workflow_show_path(scope, workflow.id))} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Failed to create workflow")} - end - end - - @impl true - def handle_event("duplicate_workflow", %{"workflow_id" => workflow_id}, socket) do - scope = socket.assigns.current_scope - - case Workflows.get_workflow(scope, workflow_id) do - {:error, :not_found} -> - {:noreply, put_flash(socket, :error, "Workflow not found")} - - {:ok, workflow} -> - case Workflows.duplicate_workflow(scope, workflow) do - {:ok, duplicated_workflow} -> - socket = - socket - |> put_flash(:info, "Workflow duplicated successfully") - |> stream_insert(:workflows, duplicated_workflow, at: 0) - - {:noreply, socket} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Failed to duplicate workflow")} - end - end - end - - @impl true - def handle_event("archive_workflow", %{"workflow_id" => workflow_id}, socket) do - scope = socket.assigns.current_scope - - case Workflows.get_workflow(scope, workflow_id) do - {:error, :not_found} -> - {:noreply, put_flash(socket, :error, "Workflow not found")} - - {:ok, workflow} -> - case Workflows.archive_workflow(scope, workflow) do - {:ok, archived_workflow} -> - socket = - socket - |> put_flash(:info, "Workflow archived successfully") - |> stream_insert(:workflows, archived_workflow) - - {:noreply, socket} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Failed to archive workflow")} - end - end - end - - @impl true - def render(assigns) do - ~H""" - - <:page_header> -
-
-
-

Automation

-
-

Workflows

-
-

- Design, publish, and monitor the automations. - Drafts stay private until you publish them. -

-
- -
- -
-
-
- - -
-
-
-
- <.data_table - id="workflows" - rows={@streams.workflows} - rows_empty?={@workflows_empty?} - tbody_class="divide-y divide-base-200" - row_click={&navigate_to_workflow(&1, @current_scope)} - row_class="cursor-pointer hover:bg-neutral/10" - > - <:col :let={workflow} label="Workflow"> -
-
-

{workflow.name}

- - v{workflow.current_version_tag} - -
-

- {workflow.description} -

-

- {short_id(workflow.id)} -

-
- - - <:col :let={workflow} label="Trigger" width="12%"> -
- <.icon name="hero-bolt" class="size-4 opacity-70" /> - {trigger_label(workflow)} -
- - - <:col :let={workflow} label="Status" width="10%" align="center"> - - {status_label(workflow.status)} - - - - <:col :let={workflow} label="Owner" width="14%"> -
- <.icon name="hero-user" class="size-4 opacity-70" /> - {owner_name(workflow)} -
- - - <:col :let={workflow} label="Access" width="10%" align="center"> - - {elem(access_state_badge(workflow, @current_scope), 0)} - - - - <:col :let={workflow} label="Updated" width="14%"> -
- <.icon name="hero-clock" class="size-4 opacity-70" /> - {formatted_timestamp(workflow.updated_at)} -
- - - <:col :let={workflow} label="Created" width="14%"> -
- {formatted_timestamp(workflow.inserted_at)} -
- - - <:col :let={workflow} label="Actions" width="10%" align="center"> -
- - -
- - - <:empty_state> -
-
- <.icon name="hero-rocket-launch" class="size-6" /> -
-
-

No workflows yet

-

Create one above to get started.

-
-
- - -
-
-
-
- """ - end - - defp sort_workflows(workflows) do - Enum.sort_by( - workflows, - fn workflow -> - workflow.updated_at || workflow.inserted_at - end, - {:desc, DateTime} - ) - end - - defp navigate_to_workflow({_, workflow}, current_scope), - do: JS.navigate(Paths.workflow_show_path(current_scope, workflow.id)) - - defp navigate_to_workflow(workflow, current_scope), - do: JS.navigate(Paths.workflow_show_path(current_scope, workflow.id)) - - # ============================================================================ - # Display Helpers - # ============================================================================ - - defp owner_name(workflow) do - case workflow.user do - nil -> "Unknown" - user -> user.email || "User #{String.slice(user.id, 0, 8)}" - end - end - - defp access_state_badge(workflow, scope) do - state = Workflows.workflow_access_state(scope, workflow) - - case state do - :admin -> - {"Admin", "badge-primary"} - - :member -> - {"Member", "badge-secondary"} - - :viewer -> - {"Viewer", "badge-ghost"} - - nil -> - {"No Access", "badge-error"} - end - end -end diff --git a/lib/fizz_web/live/workflow_live/paths.ex b/lib/fizz_web/live/workflow_live/paths.ex deleted file mode 100644 index abb1c13..0000000 --- a/lib/fizz_web/live/workflow_live/paths.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule FizzWeb.WorkflowLive.Paths do - @moduledoc false - use FizzWeb, :verified_routes - - def workflows_index_path(%{workspace: %{id: workspace_id}}), - do: ~p"/workspaces/#{workspace_id}/workflows" - - def workflow_show_path(%{workspace: %{id: workspace_id}}, workflow_id), - do: ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}" -end diff --git a/lib/fizz_web/live/workflow_live/show.ex b/lib/fizz_web/live/workflow_live/show.ex deleted file mode 100644 index d0a05ee..0000000 --- a/lib/fizz_web/live/workflow_live/show.ex +++ /dev/null @@ -1,151 +0,0 @@ -defmodule FizzWeb.WorkflowLive.Show do - @moduledoc """ - LiveView for showing workflow details and execution history. - """ - use FizzWeb, :live_view - - alias Fizz.Accounts - alias Fizz.Workflows - alias FizzWeb.WorkflowLive.Paths - import FizzWeb.Formatters - - @impl true - def mount(%{"workspace_id" => workspace_id, "id" => id}, _session, socket) do - with {:ok, scope} <- - Accounts.build_scope_for_workspace(socket.assigns.current_scope, workspace_id), - {:ok, workflow} <- Workflows.get_workflow(scope, id) do - socket = - socket - |> assign(:current_scope, scope) - |> assign(:page_title, workflow.name) - |> assign(:workflow, workflow) - - {:ok, socket} - else - {:error, :not_found} -> - {:ok, - socket - |> put_flash(:error, "Workflow not found") - |> redirect(to: ~p"/workspaces")} - - {:error, _reason} -> - {:ok, - socket - |> put_flash(:error, "Workspace not found") - |> redirect(to: ~p"/workspaces")} - end - end - - @impl true - def render(assigns) do - ~H""" - - <:page_header> -
-
-
-
- <.link - navigate={Paths.workflows_index_path(@current_scope)} - class="btn btn-ghost btn-sm" - > - <.icon name="hero-arrow-left" class="size-4" /> - Back to Workflows - -
-
-

- {@workflow.name} -

- - v{@workflow.current_version_tag} - - - {status_label(@workflow.status)} - -
-

- {@workflow.description || "No description provided."} -

-
-
- <.icon name="hero-clock" class="size-4" /> - Updated {formatted_timestamp(@workflow.updated_at)} -
-
- {short_id(@workflow.id)} -
-
-
-
-
- - -
-
-
-

- <.icon name="hero-information-circle" class="size-5" /> Workflow Details -

- -
-
-
- -
- <.icon name="hero-bolt" class="size-4 opacity-70" /> - {trigger_label(@workflow)} -
-
- -
- -
- - {status_label(@workflow.status)} - -
-
- -
- -
- {@workflow.current_version_tag || "Unversioned"} -
-
-
- -
-
- -
- - {formatted_timestamp(@workflow.inserted_at)} - -
-
- -
- -
- - {formatted_timestamp(@workflow.updated_at)} - -
-
- -
- -
- {@workflow.id} -
-
-
-
-
-
-
-
- """ - end -end diff --git a/lib/fizz_web/live/workspaces_live/index.html.heex b/lib/fizz_web/live/workspaces_live/index.html.heex index 46c25f3..1d800f1 100644 --- a/lib/fizz_web/live/workspaces_live/index.html.heex +++ b/lib/fizz_web/live/workspaces_live/index.html.heex @@ -4,7 +4,7 @@

Workspace

Workspaces

- Manage workspace boundaries and open a workspace to access Sprites. + Manage workspace boundaries and open a workspace to access the rest of the app.

@@ -81,13 +81,6 @@ > Sprites - <.link - id={"workspace-workflows-#{workspace.id}"} - navigate={~p"/workspaces/#{workspace.id}/workflows"} - class="btn btn-ghost btn-sm" - > - Workflows -
diff --git a/lib/fizz_web/live/workspaces_live/show.html.heex b/lib/fizz_web/live/workspaces_live/show.html.heex index ff5555f..eaca169 100644 --- a/lib/fizz_web/live/workspaces_live/show.html.heex +++ b/lib/fizz_web/live/workspaces_live/show.html.heex @@ -12,7 +12,7 @@ <.icon name="hero-arrow-left" class="size-4" /> Back to Workspaces

{(@workspace && @workspace.name) || "Workspace"}

-

Workspace details and quick links.

+

Workspace details and quick links for the current scope.

@@ -53,17 +53,10 @@

Quick Actions

- <.link - id="workspace-show-open-workflows" - navigate={~p"/workspaces/#{@workspace.id}/workflows"} - class="btn btn-primary btn-sm w-full" - > - Open Workflows - <.link id="workspace-show-open-sprites" navigate={~p"/workspaces/#{@workspace.id}/sprites"} - class="btn btn-outline btn-sm w-full" + class="btn btn-primary btn-sm w-full" > Open Sprites diff --git a/lib/fizz_web/router.ex b/lib/fizz_web/router.ex index 33c4efd..84518ec 100644 --- a/lib/fizz_web/router.ex +++ b/lib/fizz_web/router.ex @@ -70,9 +70,6 @@ defmodule FizzWeb.Router do live "/workspaces/:workspace_id", WorkspacesLive.Show, :show live "/workspaces/:workspace_id/sprites", SpritesLive.Index, :index live "/workspaces/:workspace_id/sprites/:sprite_id", SpritesLive.Show, :show - - live "/workspaces/:workspace_id/workflows", WorkflowLive.Index, :index - live "/workspaces/:workspace_id/workflows/:id", WorkflowLive.Show, :show end end end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index cb0baf4..6c51540 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -4,9 +4,7 @@ alias Fizz.Repo alias Fizz.Accounts -alias Fizz.Accounts.{User, Scope, Workspace, WorkspaceMembership} -alias Fizz.Workflows -alias Fizz.Workflows.Workflow +alias Fizz.Accounts.{Workspace, WorkspaceMembership} IO.puts("🌱 Seeding database...") @@ -59,8 +57,8 @@ workspace1 = ) if is_nil(user1_workspace) do - IO.puts("⚠️ No workspace found for #{user1.email}. Skipping workflow creation.") - IO.puts(" Workflows require a workspace. Please create one first.") + IO.puts("⚠️ No workspace found for #{user1.email}. Skipping workspace seed updates.") + IO.puts(" Create a workspace first, then rerun the seeds.") System.halt(0) end @@ -85,323 +83,6 @@ workspace1 = end end -# Create scope for user1 with workspace and organization -# Note: For seeds, we'll create a minimal scope. In production, use Accounts.build_scope/3 -scope1 = - Scope.for_user(user1) - |> Scope.with_organization_id(workspace1.workos_organization_id) - |> Scope.with_workspace(workspace1) - |> Scope.with_workspace_role(:admin) - |> Scope.with_organization_role(:owner) - -IO.puts("\n📋 Creating example workflows...") - -# Helper function to create workflow with draft -create_workflow_with_draft = fn attrs, steps, connections -> - case Workflows.create_workflow(scope1, attrs) do - {:ok, workflow} -> - draft_attrs = %{ - steps: steps, - connections: connections, - settings: %{timeout_ms: 300_000, max_retries: 3} - } - - case Workflows.update_workflow_draft(scope1, workflow, draft_attrs) do - {:ok, _draft} -> - workflow - - {:error, reason} -> - IO.puts( - "⚠️ Warning: Failed to create draft for workflow #{workflow.name}: #{inspect(reason)}" - ) - - workflow - end - - {:error, reason} -> - IO.puts("⚠️ Failed to create workflow #{attrs[:name]}: #{inspect(reason)}") - nil - end -end - -# ============================================================================ -# Example 1: Linear Workflow - Simple sequential data processing -# ============================================================================ -IO.puts("Creating Linear Workflow...") - -linear_steps = [ - %{ - id: "start", - type_id: "manual_input", - name: "Start", - config: %{ - "trigger_data" => - "{\"name\": \"John Doe\", \"timestamp\": \"2026-01-04 20:00:00\", \"arr\":[1,2,3,4,5]}" - }, - position: %{"x" => -161.17957584024998, "y" => -569.2425531914893} - }, - %{ - id: "format_greeting", - type_id: "format", - name: "Format Greeting", - config: %{"template" => "Hello {{json.name}}! Welcome to the workflow."}, - position: %{"x" => 40.66991013933023, "y" => 70.26862929139838} - }, - %{ - id: "add_timestamp", - type_id: "format", - name: "Add Timestamp", - config: %{"template" => "{{json.greeting}} Processed at {{json.timestamp}}"}, - position: %{"x" => 431.25889887340765, "y" => 78.97413492435965} - }, - %{ - id: "end", - type_id: "debug", - name: "End", - config: %{"message" => "Linear workflow completed"}, - position: %{"x" => 1062.82042415975, "y" => -720.2425531914893} - }, - %{ - id: "webhook_trigger", - type_id: "webhook_trigger", - name: "Webhook Trigger", - config: %{ - "http_method" => "POST", - "path" => "7f011ee3-555d-418f-bc6b-603f21983f7a", - "response_mode" => "immediate", - "validate_input" => false - }, - position: %{"x" => -161.17957584024998, "y" => -418.24255319148926} - }, - %{ - id: "split_items", - type_id: "splitter", - name: "Split Items", - config: %{"field" => "{{ json.arr }}"}, - position: %{"x" => 246.82042415975002, "y" => -493.74255319148926} - }, - %{ - id: "math", - type_id: "math", - name: "Multiply by 10", - config: %{ - "operand" => "{{ json }}", - "operation" => "multiply", - "value" => "10" - }, - position: %{"x" => 654.82042415975, "y" => -569.2425531914893} - }, - %{ - id: "math_2", - type_id: "math", - name: "Multiply by 1", - config: %{ - "operand" => "{{ json }}", - "operation" => "multiply", - "value" => "1" - }, - position: %{"x" => 654.82042415975, "y" => -418.24255319148926} - }, - %{ - id: "format_string", - type_id: "format", - name: "Format String", - config: %{"template" => "({{ json[0] }}, {{ json[1] }})"}, - position: %{"x" => 1062.82042415975, "y" => -493.74255319148926} - }, - %{ - id: "aggregate_items", - type_id: "aggregator", - name: "Aggregate Items", - config: %{}, - position: %{"x" => 1470.82042415975, "y" => -493.74255319148926} - }, - %{ - id: "format_string_2", - type_id: "format", - name: "Format String 2", - config: %{"template" => "{{ json }}"}, - position: %{"x" => 1878.82042415975, "y" => -493.74255319148926} - } -] - -linear_connections = [ - %{ - id: "start_to_format", - source_step_id: "start", - source_output: "main", - target_step_id: "format_greeting", - target_input: "main" - }, - %{ - id: "format_to_add_timestamp", - source_step_id: "format_greeting", - source_output: "main", - target_step_id: "add_timestamp", - target_input: "main" - }, - %{ - id: "add_timestamp_to_end", - source_step_id: "add_timestamp", - source_output: "main", - target_step_id: "end", - target_input: "main" - }, - %{ - id: "2bdb85af-0e52-42d5-a92e-47bad4b23c11", - source_step_id: "start", - source_output: "main", - target_step_id: "split_items", - target_input: "main" - }, - %{ - id: "0eff119f-e1dd-4e8e-8d95-fdad64b06641", - source_step_id: "split_items", - source_output: "main", - target_step_id: "math", - target_input: "main" - }, - %{ - id: "af9d53ea-c06c-4ee6-952a-c1406b937726", - source_step_id: "split_items", - source_output: "main", - target_step_id: "math_2", - target_input: "main" - }, - %{ - id: "4af4bde9-42ab-4b44-9589-d43109b5e869", - source_step_id: "math", - source_output: "main", - target_step_id: "format_string", - target_input: "main" - }, - %{ - id: "995449ee-2925-46e1-a86e-532b28efb9c7", - source_step_id: "math_2", - source_output: "main", - target_step_id: "format_string", - target_input: "main" - }, - %{ - id: "bb5c1a59-a6d1-49a3-94f7-916f07655412", - source_step_id: "format_string", - source_output: "main", - target_step_id: "aggregate_items", - target_input: "main" - }, - %{ - id: "d7a53977-73ca-4654-aa9f-fe181c55f281", - source_step_id: "aggregate_items", - source_output: "main", - target_step_id: "format_string_2", - target_input: "main" - } -] - -linear_workflow = - create_workflow_with_draft.( - %{ - name: "Linear Data Processing", - description: "A simple linear workflow that processes data sequentially" - }, - linear_steps, - linear_connections - ) - -if linear_workflow, do: IO.puts("✅ Created Linear Workflow: #{linear_workflow.name}") - -# ============================================================================ -# Example 2: Branching Workflow - Conditional processing with if/else -# ============================================================================ -IO.puts("Creating Branching Workflow...") - -branching_steps = [ - %{ - id: "input", - type_id: "manual_input", - name: "Input", - config: %{"trigger_data" => "{\"name\": \"Alice\", \"status\": \"active\"}"}, - position: %{"x" => 100, "y" => 150} - }, - %{ - id: "check_status", - type_id: "condition", - name: "Check Status", - config: %{"condition" => "{{json.status}} == 'active'"}, - position: %{"x" => 300, "y" => 150} - }, - %{ - id: "active_path", - type_id: "format", - name: "Active User", - config: %{"template" => "✅ User {{json.name}} is active"}, - position: %{"x" => 500, "y" => 100} - }, - %{ - id: "inactive_path", - type_id: "format", - name: "Inactive User", - config: %{"template" => "❌ User {{json.name}} is inactive"}, - position: %{"x" => 500, "y" => 200} - }, - %{ - id: "output", - type_id: "debug", - name: "Output", - config: %{"message" => "Branching workflow completed"}, - position: %{"x" => 700, "y" => 150} - } -] - -branching_connections = [ - %{ - id: "input_to_check", - source_step_id: "input", - source_output: "main", - target_step_id: "check_status", - target_input: "main" - }, - %{ - id: "check_to_active", - source_step_id: "check_status", - source_output: "true", - target_step_id: "active_path", - target_input: "main" - }, - %{ - id: "check_to_inactive", - source_step_id: "check_status", - source_output: "false", - target_step_id: "inactive_path", - target_input: "main" - }, - %{ - id: "active_to_output", - source_step_id: "active_path", - source_output: "main", - target_step_id: "output", - target_input: "main" - }, - %{ - id: "inactive_to_output", - source_step_id: "inactive_path", - source_output: "main", - target_step_id: "output", - target_input: "main" - } -] - -branching_workflow = - create_workflow_with_draft.( - %{ - name: "Branching User Status", - description: "Conditional workflow that routes based on user status" - }, - branching_steps, - branching_connections - ) - -if branching_workflow, do: IO.puts("✅ Created Branching Workflow: #{branching_workflow.name}") - +IO.puts("\n📋 Workflow seeds have been removed.") +IO.puts("✅ Shared workspace setup is complete.") IO.puts("\n🎉 Seeding completed!") -IO.puts("Note: Workflow sharing is handled through workspace memberships in this system.") diff --git a/test/fizz/accounts/scope_test.exs b/test/fizz/accounts/scope_test.exs index b7e9fda..da69c39 100644 --- a/test/fizz/accounts/scope_test.exs +++ b/test/fizz/accounts/scope_test.exs @@ -1,9 +1,7 @@ defmodule Fizz.Accounts.ScopeTest do use ExUnit.Case, async: true - alias Fizz.Accounts.Workspace alias Fizz.Accounts.Scope - alias Fizz.Workflows.Workflow test "for_user/1 returns nil for anonymous user" do assert Scope.for_user(nil) == nil @@ -31,58 +29,4 @@ defmodule Fizz.Accounts.ScopeTest do assert Scope.workspace_admin?(admin_scope) refute Scope.workspace_admin?(member_scope) end - - test "can_view_workflow?/2 allows viewer/member/admin in same workspace" do - scope = scope_for_workspace("workspace_123", :viewer) - workflow = %Workflow{workspace_id: "workspace_123"} - - assert Scope.can_view_workflow?(scope, workflow) - end - - test "can_edit_workflow?/2 allows member/admin in same workspace and denies viewer" do - workflow = %Workflow{workspace_id: "workspace_123"} - viewer_scope = scope_for_workspace("workspace_123", :viewer) - member_scope = scope_for_workspace("workspace_123", :member) - admin_scope = scope_for_workspace("workspace_123", :admin) - - refute Scope.can_edit_workflow?(viewer_scope, workflow) - assert Scope.can_edit_workflow?(member_scope, workflow) - assert Scope.can_edit_workflow?(admin_scope, workflow) - end - - test "can_view_workflow?/2 and can_edit_workflow?/2 deny cross-workspace access" do - scope = scope_for_workspace("workspace_abc", :admin) - workflow = %Workflow{workspace_id: "workspace_xyz"} - - refute Scope.can_view_workflow?(scope, workflow) - refute Scope.can_edit_workflow?(scope, workflow) - end - - test "organization admins can edit workflows in the active workspace even without workspace role" do - scope = - %Scope{} - |> Scope.with_workspace(%Workspace{id: "workspace_123"}) - |> Scope.with_organization_role(:owner) - |> Map.put(:user, %{id: "user_123"}) - |> Map.put(:actor, :user) - - workflow = %Workflow{workspace_id: "workspace_123"} - - assert Scope.can_edit_workflow?(scope, workflow) - assert Scope.can_view_workflow?(scope, workflow) - end - - defp scope_for_workspace(workspace_id, workspace_role) do - %Scope{} - |> Scope.with_workspace(%Workspace{ - id: workspace_id, - name: "Workspace", - slug: "workspace", - workos_organization_id: "org_123" - }) - |> Scope.with_workspace_role(workspace_role) - |> Scope.with_organization_role(:member) - |> Map.put(:user, %{id: "user_123"}) - |> Map.put(:actor, :user) - end end diff --git a/test/fizz/workflows/validator_subnodes_test.exs b/test/fizz/workflows/validator_subnodes_test.exs deleted file mode 100644 index d043d51..0000000 --- a/test/fizz/workflows/validator_subnodes_test.exs +++ /dev/null @@ -1,140 +0,0 @@ -defmodule Fizz.Workflows.ValidatorSubnodesTest do - use ExUnit.Case, async: true - - alias Fizz.Workflows.Validator - alias Fizz.Workflows.WorkflowDraft - alias Fizz.Workflows.Embeds.{Connection, Step} - - describe "validate/1 subnode slots" do - test "accepts valid slot wiring" do - draft = %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - step("agent", "ai_agent"), - step("model", "openai_model"), - step("prompt", "ai_prompt_template"), - step("tool", "ai_tool_http") - ], - connections: [ - conn("c1", "model", "agent", "model"), - conn("c2", "prompt", "agent", "prompt"), - conn("c3", "tool", "agent", "tools") - ], - groups: [] - } - - assert :ok = Validator.validate(draft) - end - - test "fails when required slots are missing" do - draft = %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - step("agent", "ai_agent"), - step("model", "openai_model") - ], - connections: [ - conn("c1", "model", "agent", "model") - ], - groups: [] - } - - assert {:error, errors} = Validator.validate(draft) - assert Enum.any?(errors, fn error -> inspect(error) =~ "required slot prompt" end) - end - - test "fails when source type is not accepted by slot" do - draft = %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - step("agent", "ai_agent"), - step("math", "math"), - step("prompt", "ai_prompt_template") - ], - connections: [ - conn("c1", "math", "agent", "model"), - conn("c2", "prompt", "agent", "prompt") - ], - groups: [] - } - - assert {:error, errors} = Validator.validate(draft) - - assert Enum.any?(errors, fn error -> - inspect(error) =~ "is not allowed for slot model" - end) - end - - test "fails when one-cardinality slot has multiple connections" do - draft = %WorkflowDraft{ - workflow_id: Ecto.UUID.generate(), - steps: [ - step("agent", "ai_agent"), - step("model_a", "openai_model"), - step("model_b", "anthropic_model"), - step("prompt", "ai_prompt_template") - ], - connections: [ - conn("c1", "model_a", "agent", "model"), - conn("c2", "model_b", "agent", "model"), - conn("c3", "prompt", "agent", "prompt") - ], - groups: [] - } - - assert {:error, errors} = Validator.validate(draft) - - assert Enum.any?(errors, fn error -> - inspect(error) =~ "allows only one sub-node connection" - end) - end - end - - defp step(id, type_id) do - config = - case type_id do - "openai_model" -> - %{ - "model" => "gpt-4.1-mini", - "credential_ref" => %{ - "id" => "cred_openai_#{id}", - "provider" => "openai_api_key", - "auth_type" => "api_key", - "owner_user_id" => "user_123" - } - } - - "anthropic_model" -> - %{ - "model" => "claude-3-5-sonnet-latest", - "credential_ref" => %{ - "id" => "cred_anthropic_#{id}", - "provider" => "anthropic_api_key", - "auth_type" => "api_key", - "owner_user_id" => "user_123" - } - } - - _ -> - %{} - end - - %Step{ - id: id, - type_id: type_id, - name: id, - config: config, - position: %{} - } - end - - defp conn(id, source, target, target_input) do - %Connection{ - id: id, - source_step_id: source, - source_output: "main", - target_step_id: target, - target_input: target_input - } - end -end diff --git a/test/fizz/workflows_test.exs b/test/fizz/workflows_test.exs deleted file mode 100644 index 4efcded..0000000 --- a/test/fizz/workflows_test.exs +++ /dev/null @@ -1,65 +0,0 @@ -defmodule Fizz.WorkflowsTest do - use ExUnit.Case, async: true - - import Ecto.Changeset - - alias Fizz.Workflows.Workflow - alias Fizz.Workflows.WorkflowDraft - alias Fizz.Workflows - - describe "workflow changesets and step identity" do - test "update_changeset/2 ignores workspace_id and user_id changes" do - workflow = %Workflow{ - name: "Original", - workspace_id: Ecto.UUID.generate(), - user_id: Ecto.UUID.generate() - } - - changeset = - Workflow.update_changeset(workflow, %{ - name: "Renamed", - workspace_id: Ecto.UUID.generate(), - user_id: Ecto.UUID.generate() - }) - - assert changeset.valid? - assert get_change(changeset, :name) == "Renamed" - refute Map.has_key?(changeset.changes, :workspace_id) - refute Map.has_key?(changeset.changes, :user_id) - end - - test "create_changeset/2 includes workspace_id and user_id" do - attrs = %{ - name: "Created", - workspace_id: Ecto.UUID.generate(), - user_id: Ecto.UUID.generate() - } - - changeset = Workflow.create_changeset(%Workflow{}, attrs) - - assert changeset.valid? - assert get_change(changeset, :workspace_id) == attrs.workspace_id - assert get_change(changeset, :user_id) == attrs.user_id - end - - test "workflow draft changeset casts editor_state" do - workflow_id = Ecto.UUID.generate() - - changeset = - WorkflowDraft.changeset(%WorkflowDraft{workflow_id: workflow_id}, %{ - workflow_id: workflow_id, - editor_state: %{"locks" => %{"step_1" => "user_1"}} - }) - - assert changeset.valid? - assert get_change(changeset, :editor_state) == %{"locks" => %{"step_1" => "user_1"}} - end - - test "generate_unique_step_identity/2 works for string-key maps" do - existing_steps = [%{"name" => "HTTP Request", "id" => "http_request"}] - - assert {"HTTP Request 2", "http_request_2"} = - Workflows.generate_unique_step_identity(existing_steps, "HTTP Request") - end - end -end diff --git a/test/fizz_web/live/workflow_live_test.exs b/test/fizz_web/live/workflow_live_test.exs deleted file mode 100644 index 3b495e5..0000000 --- a/test/fizz_web/live/workflow_live_test.exs +++ /dev/null @@ -1,20 +0,0 @@ -defmodule FizzWeb.WorkflowLiveTest do - use FizzWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - test "workflow index requires authentication", %{conn: conn} do - workspace_id = Ecto.UUID.generate() - - assert {:error, {:redirect, %{to: "/auth/workos"}}} = - live(conn, ~p"/workspaces/#{workspace_id}/workflows") - end - - test "workflow show requires authentication", %{conn: conn} do - workspace_id = Ecto.UUID.generate() - workflow_id = Ecto.UUID.generate() - - assert {:error, {:redirect, %{to: "/auth/workos"}}} = - live(conn, ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}") - end -end diff --git a/test/fizz_web/live/workflow_workspace_live_test.exs b/test/fizz_web/live/workflow_workspace_live_test.exs deleted file mode 100644 index 7c9c397..0000000 --- a/test/fizz_web/live/workflow_workspace_live_test.exs +++ /dev/null @@ -1,143 +0,0 @@ -defmodule FizzWeb.WorkflowWorkspaceLiveTest do - use FizzWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership} - alias Fizz.Repo - alias Fizz.Workflows - - defmodule WorkOSHTTPStub do - def request(opts) do - case {opts[:method], opts[:url]} do - {:get, "/user_management/organization_memberships"} -> - {:ok, - %Req.Response{ - status: 200, - body: %{ - "data" => [ - %{ - "status" => "active", - "role" => %{"slug" => "owner"} - } - ] - } - }} - - _ -> - {:ok, %Req.Response{status: 200, body: %{}}} - end - end - end - - setup %{conn: conn} do - previous_http_client = Application.get_env(:fizz, :workos_http_client_module) - previous_workos_client = Application.get_env(:workos, WorkOS.Client) - - Application.put_env(:fizz, :workos_http_client_module, WorkOSHTTPStub) - - Application.put_env(:workos, WorkOS.Client, - api_key: "test_api_key", - client_id: "test_client_id", - client: Fizz.Accounts.WorkOS.ReqClient - ) - - on_exit(fn -> - Application.put_env(:fizz, :workos_http_client_module, previous_http_client) - - case previous_workos_client do - nil -> Application.delete_env(:workos, WorkOS.Client) - value -> Application.put_env(:workos, WorkOS.Client, value) - end - end) - - user = Fizz.AccountsFixtures.user_fixture() - workspace = workspace_fixture!(user) - scope = scoped_workspace_access(user, workspace) - conn = log_in_user(conn, user) - - %{conn: conn, workspace: workspace, scope: scope} - end - - test "workflow index loads for authenticated workspace user", %{ - conn: conn, - workspace: workspace - } do - {:ok, view, _html} = live(conn, ~p"/workspaces/#{workspace.id}/workflows") - - assert has_element?(view, "#workflows") - assert has_element?(view, "#workflow-create-button") - end - - test "workflow index creates a workflow and navigates to workflow show", %{ - conn: conn, - workspace: workspace - } do - {:ok, view, _html} = live(conn, ~p"/workspaces/#{workspace.id}/workflows") - - assert {:error, {:live_redirect, %{to: to}}} = - view - |> element("#workflow-create-button") - |> render_click() - - assert to =~ ~r{^/workspaces/#{workspace.id}/workflows/[0-9a-f-]+$} - end - - test "workflow show renders workflow details without execution history", %{ - conn: conn, - workspace: workspace, - scope: scope - } do - workflow = workflow_fixture!(scope) - - {:ok, view, _html} = live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}") - - assert has_element?(view, "a[href='/workspaces/#{workspace.id}/workflows']") - assert has_element?(view, "h2", "Workflow Details") - refute has_element?(view, "h2", "Recent Executions") - end - - defp workspace_fixture!(user) do - unique = System.unique_integer([:positive]) - - workspace = - %Workspace{} - |> Workspace.changeset(%{ - name: "Workflow Workspace #{unique}", - slug: "workflow-workspace-#{unique}", - workos_organization_id: "org_#{unique}" - }) - |> Repo.insert!() - - %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id} - |> WorkspaceMembership.changeset(%{role: :admin}) - |> Repo.insert!() - - workspace - end - - defp scoped_workspace_access(user, workspace) do - Scope.for_user(user) - |> Scope.with_organization_id(workspace.workos_organization_id) - |> Scope.with_organization_role(:owner) - |> Scope.with_workspace(workspace) - |> Scope.with_workspace_role(:admin) - end - - defp workflow_fixture!(scope) do - {:ok, workflow} = - Workflows.create_workflow(scope, %{ - name: "Order Intake #{System.unique_integer([:positive])}", - description: "Handles inbound events" - }) - - {:ok, _draft} = - Workflows.update_workflow_draft(scope, workflow, %{ - steps: [], - connections: [], - groups: [] - }) - - workflow - end -end From b6858e9168784e3e8f425ff3b927ca70d362eaec Mon Sep 17 00:00:00 2001 From: Galad Dirie Date: Thu, 12 Mar 2026 13:56:08 -0400 Subject: [PATCH 006/135] Rename workspaces to projects --- TODO.md | 4 +- assets/vue/RevisionViewer.vue | 6 +- assets/vue/WorkflowEditor.vue | 16 +- assets/vue/components/flow/NodeLibrary.vue | 2 +- lib/fizz/accounts.ex | 194 ++++++------ lib/fizz/accounts/README.md | 38 +-- .../accounts/{workspace.ex => project.ex} | 23 +- ...ce_membership.ex => project_membership.ex} | 16 +- lib/fizz/accounts/scope.ex | 42 +-- lib/fizz/accounts/user.ex | 2 +- lib/fizz/integrations.ex | 42 +-- lib/fizz/sprites.ex | 286 +++++++++--------- lib/fizz/sprites/checkpoint.ex | 10 +- lib/fizz/sprites/console_session.ex | 10 +- lib/fizz/sprites/exec_job.ex | 10 +- lib/fizz/sprites/service.ex | 10 +- lib/fizz/sprites/sprite.ex | 14 +- lib/fizz/sprites/workers/exec_job_worker.ex | 14 +- lib/fizz/steps/executors/ai_agent.ex | 2 +- lib/fizz/steps/executors/gmail_send_email.ex | 4 +- .../steps/executors/slack_create_channel.ex | 2 +- .../steps/executors/slack_send_message.ex | 2 +- lib/fizz/steps/executors/slack_trigger.ex | 2 +- .../channels/sprite_console_channel.ex | 16 +- lib/fizz_web/channels/sprite_logs_channel.ex | 2 +- lib/fizz_web/components/layouts.ex | 18 +- .../index.ex | 90 +++--- .../index.html.heex | 60 ++-- lib/fizz_web/live/projects_live/show.ex | 59 ++++ .../show.html.heex | 54 ++-- lib/fizz_web/live/sprites_live/index.ex | 36 +-- .../live/sprites_live/index.html.heex | 14 +- lib/fizz_web/live/sprites_live/show.ex | 42 +-- lib/fizz_web/live/sprites_live/show.html.heex | 6 +- lib/fizz_web/live/workspaces_live/show.ex | 59 ---- ...pace_scope.ex => require_project_scope.ex} | 18 +- lib/fizz_web/router.ex | 8 +- mix.exs | 2 +- mix.lock | 40 +-- ...12172456_rename_workspaces_to_projects.exs | 126 ++++++++ priv/repo/seeds.exs | 66 ++-- .../accounts/organization_identity_test.exs | 30 +- test/fizz/accounts/scope_test.exs | 10 +- test/fizz/integrations_test.exs | 18 +- test/fizz/sprites_test.exs | 26 +- test/fizz_web/live/projects_live_test.exs | 17 ++ test/fizz_web/live/sprites_live_test.exs | 4 +- .../live/user_management_live_test.exs | 2 +- test/fizz_web/live/workspaces_live_test.exs | 17 -- test/support/fixtures/accounts_fixtures.ex | 10 +- 50 files changed, 864 insertions(+), 737 deletions(-) rename lib/fizz/accounts/{workspace.ex => project.ex} (71%) rename lib/fizz/accounts/{workspace_membership.ex => project_membership.ex} (62%) rename lib/fizz_web/live/{workspaces_live => projects_live}/index.ex (55%) rename lib/fizz_web/live/{workspaces_live => projects_live}/index.html.heex (58%) create mode 100644 lib/fizz_web/live/projects_live/show.ex rename lib/fizz_web/live/{workspaces_live => projects_live}/show.html.heex (58%) delete mode 100644 lib/fizz_web/live/workspaces_live/show.ex rename lib/fizz_web/plugs/{require_workspace_scope.ex => require_project_scope.ex} (59%) create mode 100644 priv/repo/migrations/20260312172456_rename_workspaces_to_projects.exs create mode 100644 test/fizz_web/live/projects_live_test.exs delete mode 100644 test/fizz_web/live/workspaces_live_test.exs diff --git a/TODO.md b/TODO.md index c223758..898cd43 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,4 @@ 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 \ No newline at end of file diff --git a/assets/vue/RevisionViewer.vue b/assets/vue/RevisionViewer.vue index 844f113..9cbe634 100644 --- a/assets/vue/RevisionViewer.vue +++ b/assets/vue/RevisionViewer.vue @@ -32,7 +32,7 @@ const noop = () => {};