Skip to content

Conversation

@sarbadaj
Copy link

feat(active_prompt): add and finalize mountable Rails engine (#243)

  • Add ActivePrompt engine (engine.rb, version.rb, module)
  • Isolate namespace via `isolate_namespace`
  • Wire asset pipeline (JS/CSS + manifest) and precompile hooks
  • Add engine routes with GET /health (no optional format)
  • Add minimal controller/helper stubs
  • Mount engine in dummy app and load engine before routes
  • Define `ActiveAgent::TestCase` to satisfy Zeitwerk
  • Add Minitest coverage: engine load/isolation, routes, assets
  • Stabilize route tests (assert named route and exact path)
  • Test hygiene: clean tmp/generators and remove lingering constants to avoid generator collisions

…gents#243)

- Add ActivePrompt engine (engine.rb, version.rb, module)
- Isolate namespace via \`isolate_namespace\`
- Wire asset pipeline (JS/CSS + manifest) and precompile hooks
- Add engine routes with GET /health (no optional format)
- Add minimal controller/helper stubs
- Mount engine in dummy app and load engine before routes
- Define \`ActiveAgent::TestCase\` to satisfy Zeitwerk
- Add Minitest coverage: engine load/isolation, routes, assets
- Stabilize route tests (assert named route and exact path)
- Test hygiene: clean tmp/generators and remove lingering constants to avoid generator collisions
@sarbadaj sarbadaj changed the title Create Rails Engine structure for ActivePrompt #243 Create the foundational Rails engine structure for ActivePrompt #243 Nov 17, 2025
…rator; make tests auto-migrate

- Add ActiveRecord models:
  - app/models/active_prompt/prompt.rb
  - app/models/active_prompt/message.rb
  - app/models/active_prompt/action.rb
  - app/models/active_prompt/context.rb

- Add agent DSL:
  - lib/active_agent/has_context.rb
  - enables: `has_context prompts: :prompts, messages: :messages, tools: :actions`
  - instance helpers: `add_prompt`, `remove_prompt`

- Add engine migration (SQLite-friendly: uses `t.json`):
  - db/migrate/20251123000000_create_active_prompt_core.rb

- Add install generator for host apps:
  - lib/generators/active_prompt/install/install_generator.rb
  - (optional) lib/active_prompt/railtie.rb + `require "active_prompt/railtie"`

- Wire dummy app for verification:
  - test/dummy/app/models/application_agent.rb
  - test/dummy/db/migrate/20251123001000_create_application_agents.rb  (uses `t.json`)

- Tests:
  - test/active_prompt/models_test.rb (persistence + DSL smoke tests)
  - test/active_prompt/* (engine load, routes, assets) kept
  - test(active_prompt): add validations + message ordering coverage

- Test boot: proactively migrate both dummy and engine paths:
  - test/test_helper.rb: construct MigrationContext with paths (no `connection.migration_context`)
  - still calls `ActiveRecord::Migration.maintain_test_schema!`

Notes:
- Accepts `rails g active_prompt:install` in host apps (or `railties:install:migrations FROM=active_prompt`).
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR establishes the foundational structure for ActivePrompt as a mountable Rails engine, introducing models for managing prompts, messages, actions, and contexts, along with database migrations, routes, test infrastructure, and asset pipeline integration.

Key Changes:

  • Creates a Rails engine with isolated namespace for ActivePrompt
  • Adds core models (Prompt, Message, Action, Context) with associations and validations
  • Implements ActiveAgent::HasContext concern for polymorphic prompt attachment
  • Sets up comprehensive test suite with dummy app integration

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
lib/active_prompt.rb Main entry point for the ActivePrompt engine module
lib/active_prompt/engine.rb Engine configuration with asset pipeline and i18n setup
lib/active_prompt/version.rb Version constant for the engine (0.1.0)
config/routes.rb Defines engine routes including health check endpoint
app/models/active_prompt/*.rb Core models for prompts, messages, actions, and contexts
db/migrate/20251127000000_create_active_prompt_core.rb Migration creating all ActivePrompt tables with indexes
lib/active_agent/has_context.rb Concern providing polymorphic prompt association methods
lib/active_agent/test_case.rb Minimal test case base class for Zeitwerk compatibility
lib/generators/active_prompt/install/install_generator.rb Generator for installing engine migrations into host app
test/test_helper.rb Enhanced test setup with migration handling and test hygiene
test/active_prompt/*.rb Comprehensive test coverage for engine, models, routes, and assets
test/dummy/config/application.rb Dummy app configuration loading the engine
test/dummy/config/routes.rb Mounts ActivePrompt engine at /active_prompt
test/dummy/db/migrate/*.rb Dummy app migration for test agent model
app/assets/**/* JavaScript and CSS assets with manifest configuration
app/controllers/active_prompt/application_controller.rb Base controller with CSRF protection
app/helpers/active_prompt/application_helper.rb Empty helper module for future use
docs/parts/examples/*.md Updated documentation with corrected example output

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +14 to +23
def to_runtime
{
name: name,
description: description,
template: template,
messages: messages.order(:position).map(&:attributes),
actions: actions.map(&:attributes),
metadata: metadata || {}
}
end
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling .map(&:attributes) on associations will trigger N+1 queries if the associations aren't preloaded. Consider documenting that callers should eager-load associations, or add a scope/class method that handles preloading. For example:

def to_runtime
  {
    name: name,
    description: description,
    template: template,
    messages: messages.order(:position).as_json,  # or use .pluck with specific columns
    actions: actions.as_json,
    metadata: metadata || {}
  }
end

Or provide a scope like Prompt.with_runtime_associations that preloads the data.

Suggested change
def to_runtime
{
name: name,
description: description,
template: template,
messages: messages.order(:position).map(&:attributes),
actions: actions.map(&:attributes),
metadata: metadata || {}
}
end
# NOTE: For best performance, callers should eager-load :messages and :actions
# using the .with_runtime_associations scope to avoid N+1 queries.
def to_runtime
{
name: name,
description: description,
template: template,
messages: messages.order(:position).as_json,
actions: actions.as_json,
metadata: metadata || {}
}
end
# Scope to eager-load associations needed for to_runtime
scope :with_runtime_associations, -> { includes(:messages, :actions) }

Copilot uses AI. Check for mistakes.

belongs_to :prompt, class_name: "ActivePrompt::Prompt", inverse_of: :messages

enum :role, { system: "system", user: "user", assistant: "assistant", tool: "tool" }, prefix: true
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The enum defines string values that are identical to their keys. Rails enums can infer string values automatically when using the array syntax, which would be cleaner:

enum :role, %i[system user assistant tool], prefix: true

This produces the same behavior but is more concise. However, if explicit string values are intentional for clarity, the current approach is fine.

Suggested change
enum :role, { system: "system", user: "user", assistant: "assistant", tool: "tool" }, prefix: true
enum :role, %i[system user assistant tool], prefix: true

Copilot uses AI. Check for mistakes.
# frozen_string_literal: true

ActivePrompt::Engine.routes.draw do
get "health", to: proc { [200, { "Content-Type" => "text/plain" }, ["ok"]] }, as: :health
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Using a proc for a simple health check route works but is unconventional. For better maintainability and consistency with Rails patterns, consider creating a dedicated controller:

# In app/controllers/active_prompt/health_controller.rb
module ActivePrompt
  class HealthController < ApplicationController
    def show
      render plain: "ok"
    end
  end
end

# Then in routes:
get "health", to: "health#show", as: :health

This makes testing easier and follows Rails conventions.

Suggested change
get "health", to: proc { [200, { "Content-Type" => "text/plain" }, ["ok"]] }, as: :health
get "health", to: "health#show", as: :health

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +7
$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__)
require "active_prompt"
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manually manipulating $LOAD_PATH is fragile and can lead to hard-to-debug issues. Rails engines should be loaded via proper gem mechanisms or by requiring the file directly.

Consider changing:

$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__)
require "active_prompt"

To:

require_relative "../../../lib/active_prompt"

This is more explicit and doesn't modify the global load path.

Suggested change
$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__)
require "active_prompt"
require_relative "../../../lib/active_prompt"

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +2
//= link_directory ../javascripts/active_prompt .js
//= link_directory ../stylesheets/active_prompt .css
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manifest file uses link_directory which will link all files in those directories. If you only need specific files (as configured in the engine initializer), consider using link directives for explicit control:

//= link active_prompt/application.js
//= link active_prompt/application.css

This makes the manifest consistent with the precompile configuration in engine.rb and prevents accidentally including unwanted files.

Suggested change
//= link_directory ../javascripts/active_prompt .js
//= link_directory ../stylesheets/active_prompt .css
//= link active_prompt/application.js
//= link active_prompt/application.css

Copilot uses AI. Check for mistakes.
belongs_to :prompt, class_name: "ActivePrompt::Prompt", inverse_of: :contexts

validates :agent, :prompt, presence: true
validates :label, length: { maximum: 255 }, allow_nil: true
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation validates :label, length: { maximum: 255 }, allow_nil: true sets a maximum length of 255, but the migration defines label as a string type which typically has a database-level limit of 255 characters anyway in most databases.

While this validation is not harmful, it's somewhat redundant with the database constraint. Consider either:

  1. Removing the validation if relying on database constraints
  2. Or keeping it for better error messages before hitting the database

Copilot uses AI. Check for mistakes.
t.json :metadata, null: false, default: {}
t.timestamps
end
add_index :active_prompt_contexts, [:agent_type, :agent_id, :prompt_id], unique: true, name: "idx_ap_contexts_agent_prompt"
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The custom index name idx_ap_contexts_agent_prompt is good practice for keeping index names short. However, for consistency, consider also providing custom names for the other composite indexes in this migration (lines 21 and 32) to maintain a uniform naming convention throughout the schema.

Copilot uses AI. Check for mistakes.
module ActivePrompt
module Generators
class InstallGenerator < Rails::Generators::Base
source_root File.expand_path("../../../../..", __dir__) # engine root
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source_root is set to File.expand_path("../../../../..", __dir__) which goes up 5 levels from the generator file location. This would point to the parent of the engine root, not the engine root itself.

From lib/generators/active_prompt/install/install_generator.rb:

  • __dir__ = lib/generators/active_prompt/install
  • Up 1: lib/generators/active_prompt
  • Up 2: lib/generators
  • Up 3: lib
  • Up 4: engine root ← Should stop here
  • Up 5: parent of engine root ← Currently pointing here

The correct path should be:

source_root File.expand_path("../../../..", __dir__)
Suggested change
source_root File.expand_path("../../../../..", __dir__) # engine root
source_root File.expand_path("../../../..", __dir__) # engine root

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module ActivePrompt
class Message < ApplicationRecord
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Models in the engine inherit from ApplicationRecord, but the engine doesn't define its own ApplicationRecord class. This creates a dependency on the host application's ApplicationRecord, which may not exist or may have different configurations.

Consider adding app/models/active_prompt/application_record.rb:

module ActivePrompt
  class ApplicationRecord < ActiveRecord::Base
    self.abstract_class = true
  end
end

Then have all engine models inherit from ActivePrompt::ApplicationRecord instead.

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +58
rescue => e
warn "Warning: failed to clean #{generated_dir}: #{e.message}"
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bare rescue clause catches all exceptions including SystemExit and SignalException, which is an anti-pattern. It should rescue a specific exception type instead.

Change to:

rescue StandardError => e
  warn "Warning: failed to clean #{generated_dir}: #{e.message}"
end

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants