diff --git a/server/app/models/agents/component.rb b/server/app/models/agents/component.rb index d890b358f..bd61bdf56 100644 --- a/server/app/models/agents/component.rb +++ b/server/app/models/agents/component.rb @@ -11,7 +11,12 @@ class Component < ApplicationRecord inverse_of: :target_component enum component_type: { chat_input: 0, chat_output: 1, data_storage: 2, llm_model: 3, prompt_template: 4, +<<<<<<< HEAD vector_store: 5, python_custom: 6 } +======= + vector_store: 5, python_custom: 6, conditional: 7, guardrails: 8, tool: 9, agent: 10, + knowledge_base: 11, llm_router: 12, human_in_loop: 13 } +>>>>>>> d6dadb6dd (feat(CE): add workflow approval model (#1708)) validates :name, presence: true validates :component_type, presence: true diff --git a/server/app/models/agents/workflow_approval.rb b/server/app/models/agents/workflow_approval.rb new file mode 100644 index 000000000..f69b324ac --- /dev/null +++ b/server/app/models/agents/workflow_approval.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Agents + class WorkflowApproval < ApplicationRecord + belongs_to :workflow_run, class_name: "Agents::WorkflowRun" + belongs_to :workspace + belongs_to :resolved_by, class_name: "User", optional: true + + validates :workflow_run_id, :workspace_id, :status, :message, + :temporal_workflow_id, :temporal_run_id, presence: true + + enum :status, { pending: 0, approved: 1, rejected: 2, timed_out: 3 } + + scope :active, -> { where(status: :pending) } + end +end diff --git a/server/app/models/agents/workflow_run.rb b/server/app/models/agents/workflow_run.rb index a50b7c5c1..55c85dbcf 100644 --- a/server/app/models/agents/workflow_run.rb +++ b/server/app/models/agents/workflow_run.rb @@ -12,14 +12,21 @@ class WorkflowRun < ApplicationRecord belongs_to :workspace has_one :workflow_log, class_name: "Agents::WorkflowLog", dependent: :destroy +<<<<<<< HEAD +======= + has_many :workflow_approvals, class_name: "Agents::WorkflowApproval", dependent: :destroy + has_many :llm_routing_logs, dependent: :destroy + has_many :llm_usage_logs, dependent: :destroy +>>>>>>> d6dadb6dd (feat(CE): add workflow approval model (#1708)) after_initialize :set_defaults, if: :new_record? - scope :active, -> { where(status: %i[pending in_progress]) } + scope :active, -> { where(status: %i[pending in_progress action_required]) } aasm column: :status, whiny_transitions: true do state :pending, initial: true state :in_progress + state :action_required state :completed state :failed state :cancelled @@ -28,16 +35,24 @@ class WorkflowRun < ApplicationRecord transitions from: %i[pending in_progress], to: :in_progress end + event :pause_for_approval do + transitions from: :in_progress, to: :action_required + end + + event :resume do + transitions from: :action_required, to: :in_progress + end + event :complete do transitions from: :in_progress, to: :completed end event :fail do - transitions from: %i[pending in_progress], to: :failed + transitions from: %i[pending in_progress action_required], to: :failed end event :cancel do - transitions from: %i[pending in_progress], to: :cancelled + transitions from: %i[pending in_progress action_required], to: :cancelled end end diff --git a/server/app/models/workspace.rb b/server/app/models/workspace.rb index 03ebd0a00..009dafebc 100644 --- a/server/app/models/workspace.rb +++ b/server/app/models/workspace.rb @@ -29,6 +29,7 @@ class Workspace < ApplicationRecord has_many :chat_messages, dependent: :nullify has_many :workflows, class_name: "Agents::Workflow", dependent: :destroy has_many :workflow_runs, class_name: "Agents::WorkflowRun", dependent: :destroy + has_many :workflow_approvals, class_name: "Agents::WorkflowApproval", dependent: :destroy has_many :workflow_logs, class_name: "Agents::WorkflowLog", dependent: :nullify has_many :workflow_integrations, class_name: "Agents::WorkflowIntegration", dependent: :nullify diff --git a/server/db/migrate/20260305100000_create_workflow_approvals.rb b/server/db/migrate/20260305100000_create_workflow_approvals.rb new file mode 100644 index 000000000..7b1019f77 --- /dev/null +++ b/server/db/migrate/20260305100000_create_workflow_approvals.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class CreateWorkflowApprovals < ActiveRecord::Migration[7.1] + def change + create_table :workflow_approvals do |t| + t.references :workflow_run, null: false, foreign_key: { to_table: :workflow_runs } + t.references :workspace, null: false, foreign_key: true + t.string :component_id, null: false + t.integer :status, null: false, default: 0 + t.text :message, null: false + t.jsonb :input_data + t.string :temporal_workflow_id, null: false + t.string :temporal_run_id, null: false + t.references :resolved_by, foreign_key: { to_table: :users }, null: true + t.text :resolution_note + t.datetime :timeout_at + t.string :timeout_action, default: "reject" + t.datetime :resolved_at + + t.timestamps + end + + add_index :workflow_approvals, :status + add_index :workflow_approvals, %i[workflow_run_id component_id], + unique: true, + where: "status = 0", + name: "idx_workflow_approvals_unique_pending" + end +end diff --git a/server/db/schema.rb b/server/db/schema.rb index ad5a40d21..dc443e9ac 100644 --- a/server/db/schema.rb +++ b/server/db/schema.rb @@ -619,6 +619,28 @@ t.index ["configurable_type", "configurable_id"], name: "index_visual_components_on_configurable" end + create_table "workflow_approvals", force: :cascade do |t| + t.bigint "workflow_run_id", null: false + t.bigint "workspace_id", null: false + t.string "component_id", null: false + t.integer "status", default: 0, null: false + t.text "message", null: false + t.jsonb "input_data" + t.string "temporal_workflow_id", null: false + t.string "temporal_run_id", null: false + t.bigint "resolved_by_id" + t.text "resolution_note" + t.datetime "timeout_at" + t.string "timeout_action", default: "reject" + t.datetime "resolved_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["resolved_by_id"], name: "index_workflow_approvals_on_resolved_by_id" + t.index ["status"], name: "index_workflow_approvals_on_status" + t.index ["workflow_run_id"], name: "index_workflow_approvals_on_workflow_run_id" + t.index ["workspace_id"], name: "index_workflow_approvals_on_workspace_id" + end + create_table "workflow_integrations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.jsonb "metadata", null: false t.integer "workspace_id", null: false @@ -716,6 +738,13 @@ add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "taggings", "tags" +<<<<<<< HEAD +======= + add_foreign_key "tools", "workspaces" + add_foreign_key "workflow_approvals", "users", column: "resolved_by_id" + add_foreign_key "workflow_approvals", "workflow_runs" + add_foreign_key "workflow_approvals", "workspaces" +>>>>>>> d6dadb6dd (feat(CE): add workflow approval model (#1708)) add_foreign_key "workflow_integrations", "workflows", validate: false add_foreign_key "workflow_integrations", "workspaces", validate: false add_foreign_key "workflow_runs", "workspaces" diff --git a/server/spec/factories/agents/workflow_approvals.rb b/server/spec/factories/agents/workflow_approvals.rb new file mode 100644 index 000000000..eb2f9972d --- /dev/null +++ b/server/spec/factories/agents/workflow_approvals.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :workflow_approval, class: "Agents::WorkflowApproval" do + association :workflow_run, factory: :workflow_run + association :workspace + component_id { "hitl-#{SecureRandom.hex(4)}" } + status { :pending } + message { "Please review and approve this workflow step." } + input_data { { "input_text" => "Sample input data" } } + temporal_workflow_id { "workflow-#{SecureRandom.hex(8)}-dag-#{SecureRandom.hex(4)}" } + temporal_run_id { SecureRandom.uuid } + timeout_action { "reject" } + + trait :pending do + status { :pending } + end + + trait :approved do + status { :approved } + resolved_at { Time.current } + resolution_note { "Approved" } + end + + trait :rejected do + status { :rejected } + resolved_at { Time.current } + resolution_note { "Rejected" } + end + + trait :timed_out do + status { :timed_out } + resolved_at { Time.current } + end + + trait :with_timeout do + timeout_at { 24.hours.from_now } + timeout_action { "reject" } + end + + trait :with_resolved_by do + association :resolved_by, factory: :user + resolved_at { Time.current } + end + end +end diff --git a/server/spec/factories/agents/workflow_runs.rb b/server/spec/factories/agents/workflow_runs.rb index 8437be5eb..25f7cf523 100644 --- a/server/spec/factories/agents/workflow_runs.rb +++ b/server/spec/factories/agents/workflow_runs.rb @@ -30,6 +30,10 @@ status { "cancelled" } end + trait :action_required do + status { "action_required" } + end + trait :with_inputs do inputs { { "key1" => "value1", "key2" => { "nested" => "value" } } } end diff --git a/server/spec/models/agents/component_spec.rb b/server/spec/models/agents/component_spec.rb index ffb21d310..c22505549 100644 --- a/server/spec/models/agents/component_spec.rb +++ b/server/spec/models/agents/component_spec.rb @@ -19,7 +19,18 @@ llm_model: 3, prompt_template: 4, vector_store: 5, +<<<<<<< HEAD python_custom: 6 +======= + python_custom: 6, + conditional: 7, + guardrails: 8, + tool: 9, + agent: 10, + knowledge_base: 11, + llm_router: 12, + human_in_loop: 13 +>>>>>>> d6dadb6dd (feat(CE): add workflow approval model (#1708)) ) } end diff --git a/server/spec/models/agents/workflow_approval_spec.rb b/server/spec/models/agents/workflow_approval_spec.rb new file mode 100644 index 000000000..85b3a7d7e --- /dev/null +++ b/server/spec/models/agents/workflow_approval_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Agents::WorkflowApproval, type: :model do + describe "associations" do + it { should belong_to(:workflow_run).class_name("Agents::WorkflowRun") } + it { should belong_to(:workspace) } + it { should belong_to(:resolved_by).class_name("User").optional } + end + + describe "validations" do + it { should validate_presence_of(:workflow_run_id) } + it { should validate_presence_of(:workspace_id) } + it { should validate_presence_of(:status) } + it { should validate_presence_of(:message) } + it { should validate_presence_of(:temporal_workflow_id) } + it { should validate_presence_of(:temporal_run_id) } + end + + describe "enum" do + it { should define_enum_for(:status).with_values(pending: 0, approved: 1, rejected: 2, timed_out: 3) } + end + + describe "scopes" do + let!(:pending_approval) { create(:workflow_approval, :pending) } + let!(:approved_approval) { create(:workflow_approval, :approved) } + let!(:rejected_approval) { create(:workflow_approval, :rejected) } + let!(:timed_out_approval) { create(:workflow_approval, :timed_out) } + + describe ".active" do + it "returns only pending approvals" do + expect(described_class.active).to include(pending_approval) + expect(described_class.active).not_to include(approved_approval, rejected_approval, timed_out_approval) + end + end + end + + describe "factory" do + it "creates a valid workflow_approval" do + approval = create(:workflow_approval) + expect(approval).to be_valid + expect(approval).to be_pending + end + + it "creates approved approval" do + approval = create(:workflow_approval, :approved) + expect(approval).to be_approved + expect(approval.resolved_at).to be_present + end + + it "creates rejected approval" do + approval = create(:workflow_approval, :rejected) + expect(approval).to be_rejected + end + + it "creates timed_out approval" do + approval = create(:workflow_approval, :timed_out) + expect(approval).to be_timed_out + end + end + + describe "attributes" do + let(:approval) { create(:workflow_approval) } + + it "stores input_data as jsonb" do + approval.update!(input_data: { "key" => "value", "nested" => { "a" => 1 } }) + expect(approval.reload.input_data).to eq({ "key" => "value", "nested" => { "a" => 1 } }) + end + + it "stores temporal workflow and run IDs" do + expect(approval.temporal_workflow_id).to be_present + expect(approval.temporal_run_id).to be_present + end + + it "allows optional fields to be nil" do + approval = create(:workflow_approval, resolved_by: nil, resolution_note: nil, timeout_at: nil) + expect(approval).to be_valid + end + end +end diff --git a/server/spec/models/agents/workflow_run_spec.rb b/server/spec/models/agents/workflow_run_spec.rb index 716e7f895..c9ff3d75d 100644 --- a/server/spec/models/agents/workflow_run_spec.rb +++ b/server/spec/models/agents/workflow_run_spec.rb @@ -7,6 +7,12 @@ it { should belong_to(:workflow).class_name("Agents::Workflow") } it { should belong_to(:workspace) } it { should have_one(:workflow_log).class_name("Agents::WorkflowLog").dependent(:destroy) } +<<<<<<< HEAD +======= + it { should have_many(:workflow_approvals).class_name("Agents::WorkflowApproval").dependent(:destroy) } + it { should have_many(:llm_routing_logs).dependent(:destroy) } + it { should have_many(:llm_usage_logs).dependent(:destroy) } +>>>>>>> d6dadb6dd (feat(CE): add workflow approval model (#1708)) end describe "validations" do @@ -25,12 +31,13 @@ describe "scopes" do let!(:pending_run) { create(:workflow_run, status: "pending") } let!(:in_progress_run) { create(:workflow_run, status: "in_progress") } + let!(:action_required_run) { create(:workflow_run, status: "action_required") } let!(:completed_run) { create(:workflow_run, status: "completed") } let!(:failed_run) { create(:workflow_run, status: "failed") } describe ".active" do - it "returns only pending and in_progress runs" do - expect(Agents::WorkflowRun.active).to include(pending_run, in_progress_run) + it "returns pending, in_progress, and action_required runs" do + expect(Agents::WorkflowRun.active).to include(pending_run, in_progress_run, action_required_run) expect(Agents::WorkflowRun.active).not_to include(completed_run, failed_run) end end @@ -125,11 +132,67 @@ expect(workflow_run).to be_cancelled end + it "transitions from action_required to cancelled" do + workflow_run.start! + workflow_run.pause_for_approval! + expect(workflow_run).to be_action_required + workflow_run.cancel! + expect(workflow_run).to be_cancelled + end + it "does not allow transition from terminal states" do workflow_run.cancel! expect { workflow_run.cancel! }.to raise_error(AASM::InvalidTransition) end end + + describe "pause_for_approval event" do + it "transitions from in_progress to action_required" do + workflow_run.start! + expect(workflow_run).to be_in_progress + workflow_run.pause_for_approval! + expect(workflow_run).to be_action_required + end + + it "does not allow transition from pending" do + expect { workflow_run.pause_for_approval! }.to raise_error(AASM::InvalidTransition) + end + + it "does not allow transition from completed" do + workflow_run.start! + workflow_run.complete! + expect { workflow_run.pause_for_approval! }.to raise_error(AASM::InvalidTransition) + end + end + + describe "resume event" do + it "transitions from action_required to in_progress" do + workflow_run.start! + workflow_run.pause_for_approval! + expect(workflow_run).to be_action_required + workflow_run.resume! + expect(workflow_run).to be_in_progress + end + + it "does not allow transition from pending" do + expect { workflow_run.resume! }.to raise_error(AASM::InvalidTransition) + end + + it "does not allow transition from in_progress" do + workflow_run.start! + expect { workflow_run.resume! }.to raise_error(AASM::InvalidTransition) + end + end + + describe "fail event from action_required" do + it "transitions from action_required to failed" do + workflow_run.start! + workflow_run.pause_for_approval! + expect(workflow_run).to be_action_required + workflow_run.fail! + expect(workflow_run).to be_failed + end + end end describe "instance methods" do