From 711685a3adce421c6705ef82e0c7e0a30840a216 Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Fri, 14 Aug 2026 12:39:49 -0700 Subject: [PATCH 1/3] fix(actionagent): install and mount cleanly on MySQL and acronym hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things stopped a plain `mount ActionAgent::Engine` from working outside a PostgreSQL host with default inflections. Both were found installing the engine into a Rails 8 app on MySQL 8 that declares `inflect.acronym "API"`. Migrations no longer set a default on JSON columns outside PostgreSQL. Both templates already chose the column type per adapter — jsonb on PostgreSQL, json elsewhere — but kept `default: []` / `default: {}` for every adapter, and MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or JSON column can't have a default value"). That aborts create_table, so `rails g action_agent:install && rails db:migrate` could not complete: 3 columns in the traces migration, 31 in the dashboard one. The paired `null: false` goes with the default, since nothing assigns those attributes before validation and a NOT NULL column with no default would reject the inserts the default existed to satisfy. Every JSON column is read through `Array(...)` / `|| {}`, so a NULL reads as the empty value. The api namespace now resolves under either spelling of the constant. An engine's files are autoloaded by the host's `rails.main` loader under the host's inflections, so a host that declares the API acronym made Zeitwerk expect ActionAgent::API::TracesController from a file defining ActionAgent::Api::TracesController, and every request to the mount raised Zeitwerk::NameError. Rails also resolves a route's controller by camelizing the stored path with the host's global inflections, which no engine-level setting scopes, so fixing the autoloader alone still left the router raising NameError. No single module name fixes both kinds of host — a plain host camelizes api to Api, an acronym host to API — so the autoloader is pinned to Api for this engine's own path only, and the namespace answers to API as well. --- actionagent/lib/action_agent/engine.rb | 41 ++++++++++ ...reate_active_agent_dashboard_tables.rb.erb | 79 +++++++++++-------- ...reate_active_agent_telemetry_traces.rb.erb | 21 ++++- 3 files changed, 105 insertions(+), 36 deletions(-) diff --git a/actionagent/lib/action_agent/engine.rb b/actionagent/lib/action_agent/engine.rb index 6a7ffbee..c8e19cc9 100644 --- a/actionagent/lib/action_agent/engine.rb +++ b/actionagent/lib/action_agent/engine.rb @@ -26,6 +26,47 @@ class Engine < ::Rails::Engine app.config.filter_parameters += [ :credential, :api_key, :access_token ] end + # The controllers under app/controllers/action_agent/api are ActionAgent::Api, + # but an engine's files are autoloaded by the host's `rails.main` loader, + # under the host's inflections. A host that declares `inflect.acronym "API"` + # — common enough that Rails documents it — makes Zeitwerk expect + # ActionAgent::API::TracesController in a file that defines + # ActionAgent::Api::TracesController, and every request to the mount raises + # Zeitwerk::NameError. + # + # Scoped to this engine's own path rather than set through `inflect`: the + # loader is shared, so a blanket rule would re-spell the host's own API + # constants. `camelize` receives the absolute path, which is the only hook + # that can tell this engine's api/ from the host's. + initializer "action_agent.inflections", before: :set_autoload_paths do + engine_root = root.to_s + + Rails.autoloaders.main.inflector.singleton_class.prepend(Module.new do + define_method(:camelize) do |basename, abspath| + next "Api" if basename == "api" && abspath.to_s.start_with?(engine_root) + + super(basename, abspath) + end + end) + end + + # Rails resolves a route's controller by camelizing the stored path + # ("action_agent/api/traces") with the host's *global* inflections, and no + # engine-level setting scopes that — so an acronym host looks up + # ActionAgent::API::TracesController and raises NameError even though the + # autoloader named the module correctly above. No single spelling satisfies + # both kinds of host: a plain host camelizes to Api, an acronym host to API. + # So the namespace answers to both. `const_missing` rather than an eager + # alias because the controllers are autoloaded on demand, and naming them at + # boot would load the whole dashboard. + ActionAgent.singleton_class.prepend(Module.new do + def const_missing(name) + return const_get(:Api) if name == :API + + super + end + end) + initializer "action_agent.assets", before: :append_assets_path do |app| builds = root.join("app", "assets", "builds").to_s next unless File.directory?(builds) diff --git a/actionagent/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb b/actionagent/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb index 5142afab..b1f2057a 100644 --- a/actionagent/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +++ b/actionagent/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb @@ -26,13 +26,13 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.string :agent_class_name t.string :action_name t.integer :status, default: 0, null: false - t.column :appearance, json_type, default: {} - t.column :instruction_sets, json_type, default: [] - t.column :tools, json_type, default: [] - t.column :mcp_servers, json_type, default: [] - t.column :model_config, json_type, default: {} - t.column :response_format, json_type, default: {} - t.column :action_prompts, json_type, default: [], null: false + t.column :appearance, json_type, **json_default({}) + t.column :instruction_sets, json_type, **json_default([]) + t.column :tools, json_type, **json_default([]) + t.column :mcp_servers, json_type, **json_default([]) + t.column :model_config, json_type, **json_default({}) + t.column :response_format, json_type, **json_default({}) + t.column :action_prompts, json_type, **json_default([], null: false) # Agents discovered from reported telemetry rather than authored here. t.string :service_name @@ -52,7 +52,7 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve create_table "#{prefix}agent_versions" do |t| t.bigint :agent_id, null: false t.integer :version_number, default: 1, null: false - t.column :configuration_snapshot, json_type, default: {}, null: false + t.column :configuration_snapshot, json_type, **json_default({}, null: false) t.string :change_summary t.string :created_by t.timestamps @@ -65,10 +65,10 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.string :action_name t.integer :status, default: 0, null: false t.text :input_prompt - t.column :input_params, json_type, default: {} + t.column :input_params, json_type, **json_default({}) t.text :output - t.column :output_metadata, json_type, default: {} - t.column :logs, json_type, default: [] + t.column :output_metadata, json_type, **json_default({}) + t.column :logs, json_type, **json_default([]) t.text :error_message t.text :error_backtrace t.integer :input_tokens @@ -93,11 +93,11 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.string :model, default: "gpt-4o-mini" t.text :instructions t.string :preset_type - t.column :appearance, json_type, default: {} - t.column :instruction_sets, json_type, default: [] - t.column :tools, json_type, default: [] - t.column :mcp_servers, json_type, default: {} - t.column :model_config, json_type, default: {} + t.column :appearance, json_type, **json_default({}) + t.column :instruction_sets, json_type, **json_default([]) + t.column :tools, json_type, **json_default([]) + t.column :mcp_servers, json_type, **json_default({}) + t.column :model_config, json_type, **json_default({}) t.boolean :featured, default: false t.boolean :free_tier, default: false t.boolean :public, default: true @@ -114,7 +114,7 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.string :contextable_type t.bigint :contextable_id t.text :instructions - t.column :options, json_type, default: {} + t.column :options, json_type, **json_default({}) t.string :trace_id t.integer :total_input_tokens, default: 0 t.integer :total_output_tokens, default: 0 @@ -130,11 +130,11 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.string :content_checksum t.string :tool_call_id t.string :tool_name - t.column :tool_arguments, json_type, default: {} + t.column :tool_arguments, json_type, **json_default({}) t.column :tool_result, json_type - t.column :attachments, json_type, default: [] - t.column :metadata, json_type, default: {} - t.column :provenance, json_type, default: {} + t.column :attachments, json_type, **json_default([]) + t.column :metadata, json_type, **json_default({}) + t.column :provenance, json_type, **json_default({}) t.timestamps t.index :agent_context_id end @@ -150,9 +150,9 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.integer :cached_tokens, default: 0 t.integer :reasoning_tokens, default: 0 t.float :duration_seconds - t.column :tool_calls, json_type, default: [] + t.column :tool_calls, json_type, **json_default([]) t.column :raw_response, json_type - t.column :provenance, json_type, default: {} + t.column :provenance, json_type, **json_default({}) t.string :trace_id t.timestamps t.index :agent_context_id @@ -183,8 +183,8 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.string :judge_kind, default: "rules", null: false t.string :judge_model t.integer :sample_size, default: 20, null: false - t.column :criteria, json_type, default: [], null: false - t.column :config, json_type, default: {}, null: false + t.column :criteria, json_type, **json_default([], null: false) + t.column :config, json_type, **json_default({}, null: false) t.timestamps t.index [ :agent_id, :name ], unique: true end @@ -192,7 +192,7 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve create_table "#{prefix}evaluation_runs" do |t| t.bigint :evaluation_id, null: false t.integer :status, default: 0, null: false - t.column :scores, json_type, default: {} + t.column :scores, json_type, **json_default({}) t.integer :samples_evaluated, default: 0 t.integer :samples_passed, default: 0 t.text :error_message @@ -208,11 +208,11 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.string :sandbox_type, default: "playwright_mcp" t.string :cloud_run_job_id t.string :cloud_run_url - t.column :runs, json_type, default: [] + t.column :runs, json_type, **json_default([]) # MCP servers this session was started with, so the MCP Services view # can show a launched server as running rather than offering to start # a second copy. - t.column :mcp_servers, json_type, default: [] + t.column :mcp_servers, json_type, **json_default([]) t.integer :runs_count, default: 0 t.integer :max_runs, default: 10 t.integer :timeout_seconds, default: 300 @@ -234,7 +234,7 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.integer :status, default: 0, null: false t.text :result t.text :error - t.column :screenshots, json_type, default: [] + t.column :screenshots, json_type, **json_default([]) t.integer :tokens_used, default: 0 t.integer :duration_ms t.datetime :started_at @@ -250,7 +250,7 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.integer :status, default: 0, null: false t.integer :action_count, default: 0 t.integer :duration_ms - t.column :metadata, json_type, default: {} + t.column :metadata, json_type, **json_default({}) t.bigint :user_id t.bigint :account_id t.timestamps @@ -269,7 +269,7 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve t.integer :timestamp_ms, null: false t.string :screenshot_key t.string :dom_snapshot_key - t.column :metadata, json_type, default: {} + t.column :metadata, json_type, **json_default({}) t.timestamps t.index [ :session_recording_id, :sequence ], unique: true, name: "index_active_agent_recording_actions_on_recording_and_sequence" @@ -314,6 +314,21 @@ class CreateActiveAgentDashboardTables < ActiveRecord::Migration<%= migration_ve # jsonb where the adapter has it, json where it doesn't. def json_type - @json_type ||= connection.adapter_name.to_s.downcase.include?("postgres") ? :jsonb : :json + @json_type ||= postgres? ? :jsonb : :json + end + + # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or + # JSON column can't have a default value"), which aborts create_table, so the + # column is created without one there. `null: false` is dropped with it: + # nothing assigns these before validation, so a NOT NULL column with no + # default would reject the very inserts the default was there to satisfy. + def json_default(value, null: nil) + return {} unless postgres? + + null.nil? ? { default: value } : { default: value, null: null } + end + + def postgres? + connection.adapter_name.to_s.downcase.include?("postgres") end end diff --git a/actionagent/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb b/actionagent/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb index 6b855c39..f42fbb6b 100644 --- a/actionagent/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +++ b/actionagent/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb @@ -15,9 +15,9 @@ class CreateActiveAgentTelemetryTraces < ActiveRecord::Migration<%= migration_ve t.string :service_name t.string :environment t.datetime :timestamp, index: true - t.column :spans, json_type, default: [] - t.column :resource_attributes, json_type, default: {} - t.column :sdk_info, json_type, default: {} + t.column :spans, json_type, **json_default([]) + t.column :resource_attributes, json_type, **json_default({}) + t.column :sdk_info, json_type, **json_default({}) t.decimal :total_duration_ms, precision: 12, scale: 3 t.integer :total_input_tokens, default: 0 t.integer :total_output_tokens, default: 0 @@ -53,6 +53,19 @@ class CreateActiveAgentTelemetryTraces < ActiveRecord::Migration<%= migration_ve # traverses spans in SQL on PostgreSQL, and jsonb_array_elements rejects a # json argument, so the column type and the query have to agree. def json_type - @json_type ||= connection.adapter_name.to_s.downcase.include?("postgres") ? :jsonb : :json + @json_type ||= postgres? ? :jsonb : :json + end + + # MySQL rejects a default on a JSON column outright ("BLOB, TEXT, GEOMETRY or + # JSON column can't have a default value"), which aborts create_table, so the + # column is created without one there. ActionAgent::TelemetryTrace reads every + # JSON column through `Array(...)`/`|| {}`, and ingest always assigns all + # three, so a NULL reads the same as the empty default would. + def json_default(value) + postgres? ? { default: value } : {} + end + + def postgres? + connection.adapter_name.to_s.downcase.include?("postgres") end end From 2f348599138da275ca9140c1fb650b9395678638 Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Fri, 14 Aug 2026 12:40:14 -0700 Subject: [PATCH 2/3] chore(actionagent): release 1.2.1 --- CHANGELOG.md | 24 +++++++++++++++++++++++- actionagent/lib/action_agent/version.rb | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1305fbf6..5ab564f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.2.0] - Unreleased +## [1.2.1] - 2026-08-14 + +### Fixed + +- **`actionagent`: the install migrations now run on MySQL.** Both templates + already chose the JSON column type per adapter, but kept `default: []` / + `default: {}` for every adapter, and MySQL rejects a default on a JSON + column outright — so `rails g action_agent:install && rails db:migrate` + aborted mid-`create_table` on any MySQL host. The default (and the paired + `null: false`, which without it would reject the inserts the default + existed to satisfy) is now PostgreSQL-only. Every JSON column is read + through `Array(...)` / `|| {}`, so a NULL reads as the empty value. +- **`actionagent`: the mount works in a host that declares + `inflect.acronym "API"`.** An engine's files are autoloaded under the + host's inflections, so such a host made Zeitwerk expect + `ActionAgent::API::TracesController` from a file defining + `ActionAgent::Api::TracesController`, and every request to the mount + raised `Zeitwerk::NameError`. Rails separately camelizes a route's stored + controller path with the host's global inflections, which no engine-level + setting scopes. The autoloader is now pinned to `Api` for this engine's + own path, and the namespace answers to `API` as well. + +## [1.2.0] - 2026-08-14 ### ⚠️ The dashboard has moved to its own gem diff --git a/actionagent/lib/action_agent/version.rb b/actionagent/lib/action_agent/version.rb index bb799171..c09da17b 100644 --- a/actionagent/lib/action_agent/version.rb +++ b/actionagent/lib/action_agent/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ActionAgent - VERSION = "1.2.0" + VERSION = "1.2.1" end From 01c76a30c4c22e4a7e2ffa1fd139e221f6a5ece4 Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Fri, 14 Aug 2026 13:49:11 -0700 Subject: [PATCH 3/3] release: skip a gem version already on RubyGems The two gems in this repo share one release tag but version independently, so a release that patches only one of them rebuilds the other at its current version. `gem push` rejects that duplicate with a non-zero status, and because both pushes are one shell block, the rejection takes down the gem that actually changed. 1.2.1 is the first release to hit this: it patches actionagent alone, leaving activeagent at the 1.2.0 already published. --- .github/workflows/release.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4620c0c8..1c0dd22e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,6 +64,31 @@ jobs: - name: Publish the framework, then the dashboard that depends on it # actionagent declares activeagent >= 1.2, so publishing it first # would leave a release nobody can bundle until the framework lands. + # + # A version already on RubyGems is skipped rather than fatal: the two + # gems share this tag but version independently, so a release that + # patches only one of them rebuilds the other at its current version. + # Without the skip, `gem push` rejects that duplicate and takes the + # gem that actually changed down with it. run: | - gem push pkg/activeagent-*.gem - gem push pkg/actionagent-*.gem + publish() { + local gem_path + gem_path=$(ls $1) + local name version + name=$(basename "$gem_path" .gem | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+.*$//') + version=$(basename "$gem_path" .gem | sed -E "s/^${name}-//") + + # `gem list --all` prints one line: `name (1.2.0, 1.1.0, ...)`, so the + # version is matched between its delimiters — anchoring on "($version" + # alone would only ever see the newest release. + if gem list --remote --exact --all "$name" | tr -d ' ' | + grep -qE "[(,]${version//./\\.}[,)]"; then + echo "$name $version is already on RubyGems; skipping." + return 0 + fi + + gem push "$gem_path" + } + + publish "pkg/activeagent-*.gem" + publish "pkg/actionagent-*.gem"