diff --git a/Dockerfile b/Dockerfile index de91f09..9d310b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,6 +86,10 @@ USER 1000:1000 # Install Claude Code as the rails user RUN curl -fsSL https://claude.ai/install.sh | bash +# Pre-trust the agent workspace so Claude Code honours agent/.claude/settings.json +# (otherwise every headless run logs "this workspace has not been trusted"). +RUN printf '{"projects":{"/rails/agent":{"hasTrustDialogAccepted":true}}}\n' > /home/rails/.claude.json + ENV PATH="/home/rails/.local/bin:$PATH" # Entrypoint prepares the database. diff --git a/app/jobs/agent_evaluate_commitment_job.rb b/app/jobs/agent_evaluate_commitment_job.rb index 5d9c9c0..b1783b6 100644 --- a/app/jobs/agent_evaluate_commitment_job.rb +++ b/app/jobs/agent_evaluate_commitment_job.rb @@ -1,9 +1,9 @@ -require "open3" - class AgentEvaluateCommitmentJob < ApplicationJob + include GoodJob::ActiveJobExtensions::Concurrency + include RunsClaudeAgent + queue_as :default - include GoodJob::ActiveJobExtensions::Concurrency good_job_control_concurrency_with( perform_limit: 5, enqueue_limit: 550, @@ -14,18 +14,15 @@ class AgentEvaluateCommitmentJob < ApplicationJob retry_on StandardError, wait: 30.seconds, attempts: 3 def perform(commitment, trigger_type: "manual", as_of_date: nil) - agent_dir = Rails.root.join("agent") current_date = as_of_date || Date.today.iso8601 prompt = format(AgentPrompts::EVALUATE_COMMITMENT_PROMPT, commitment_id: commitment.id, current_date: current_date) hook_script = agent_dir.join(".claude/hooks/on_stop_commitment.sh").to_s Rails.logger.info("AgentEvaluateCommitmentJob: Evaluating commitment #{commitment.id} (#{trigger_type})") - exit_status = stream_agent( - agent_env(commitment_id: commitment.id), - build_cmd(prompt, hook_script: hook_script), - chdir: agent_dir.to_s - ) + preflight_agent_api! + + exit_status = run_agent(prompt, hook_script: hook_script, commitment_id: commitment.id) unless exit_status.success? raise "Agent evaluation failed for commitment #{commitment.id} (exit #{exit_status.exitstatus})" @@ -33,70 +30,4 @@ def perform(commitment, trigger_type: "manual", as_of_date: nil) Rails.logger.info("AgentEvaluateCommitmentJob: Success for commitment #{commitment.id}") end - - private - - def stream_agent(env, cmd, chdir:) - Open3.popen2e(env, *cmd, chdir: chdir) do |stdin, output, thread| - stdin.close - output.each_line do |line| - STDERR.print(line) - STDERR.flush - end - thread.value - end - end - - def build_cmd(prompt, hook_script:) - hook_settings = { - "hooks" => { - "Stop" => [ { "hooks" => [ { "type" => "command", "command" => hook_script, "async" => true, "timeout" => 10 } ] } ] - } - }.to_json - - [ - "claude", "-p", prompt, - "--system-prompt", system_prompt, - "--allowedTools", allowed_tools.join(","), - "--permission-mode", "bypassPermissions", - "--model", ENV.fetch("AGENT_MODEL", "claude-sonnet-5"), - "--output-format", "text", - "--settings", hook_settings - ] - end - - def allowed_tools - [ - "Bash(curl *)", - "WebFetch(https://*.canada.ca/*)", - "WebFetch(https://*.gc.ca/*)", - "WebFetch(https://www.parl.ca/*)", - "WebSearch" - ] - end - - def system_prompt - AgentPrompts::SYSTEM_PROMPT + api_context - end - - def api_context - url = ENV.fetch("RAILS_API_URL", "http://localhost:3000") - key = Rails.application.credentials.dig(:agent, :api_key) || ENV["AGENT_API_KEY"] - "\n\n## Rails API Connection\nBase URL: `#{url}`\nAuth header: `Authorization: Bearer #{key}`\nSee CLAUDE.md for endpoint details and enum values.\n" - end - - def agent_env(commitment_id: nil, entry_id: nil) - { - "PATH" => ENV["PATH"], - "CLAUDE_CODE_OAUTH_TOKEN" => ENV["CLAUDE_CODE_OAUTH_TOKEN"], - "RAILS_API_URL" => ENV.fetch("RAILS_API_URL", "http://localhost:3000"), - "RAILS_API_KEY" => Rails.application.credentials.dig(:agent, :api_key) || ENV["AGENT_API_KEY"], - "AGENT_MODEL" => ENV.fetch("AGENT_MODEL", "claude-sonnet-5"), - "COMMITMENT_ID" => commitment_id&.to_s, - "ENTRY_ID" => entry_id&.to_s, - # Explicitly unset — subprocess must not access Rails credentials - "RAILS_MASTER_KEY" => nil, - "SECRET_KEY_BASE" => nil - }.compact - end end diff --git a/app/jobs/agent_process_entry_job.rb b/app/jobs/agent_process_entry_job.rb index 86d0c86..26eae39 100644 --- a/app/jobs/agent_process_entry_job.rb +++ b/app/jobs/agent_process_entry_job.rb @@ -1,23 +1,20 @@ -require "open3" - class AgentProcessEntryJob < ApplicationJob + include RunsClaudeAgent + queue_as :default retry_on StandardError, wait: 30.seconds, attempts: 3 def perform(entry) - agent_dir = Rails.root.join("agent") current_date = Date.today.iso8601 prompt = format(AgentPrompts::PROCESS_ENTRY_PROMPT, entry_id: entry.id, current_date: current_date) hook_script = agent_dir.join(".claude/hooks/on_stop_entry.sh").to_s Rails.logger.info("AgentProcessEntryJob: Processing entry #{entry.id} (#{entry.title})") - exit_status = stream_agent( - agent_env(entry_id: entry.id), - build_cmd(prompt, hook_script: hook_script), - chdir: agent_dir.to_s - ) + preflight_agent_api! + + exit_status = run_agent(prompt, hook_script: hook_script, entry_id: entry.id) unless exit_status.success? raise "Agent processing failed for entry #{entry.id} (exit #{exit_status.exitstatus})" @@ -25,66 +22,4 @@ def perform(entry) Rails.logger.info("AgentProcessEntryJob: Success for entry #{entry.id}") end - - private - - def stream_agent(env, cmd, chdir:) - Open3.popen2e(env, *cmd, chdir: chdir) do |stdin, output, thread| - stdin.close - output.each_line { |line| STDERR.print(line); STDERR.flush } - thread.value - end - end - - def build_cmd(prompt, hook_script:) - hook_settings = { - "hooks" => { - "Stop" => [ { "hooks" => [ { "type" => "command", "command" => hook_script, "async" => true, "timeout" => 10 } ] } ] - } - }.to_json - - [ - "claude", "-p", prompt, - "--system-prompt", system_prompt, - "--allowedTools", allowed_tools.join(","), - "--permission-mode", "bypassPermissions", - "--model", ENV.fetch("AGENT_MODEL", "claude-sonnet-5"), - "--output-format", "text", - "--settings", hook_settings - ] - end - - def allowed_tools - [ - "Bash(curl *)", - "WebFetch(https://*.canada.ca/*)", - "WebFetch(https://*.gc.ca/*)", - "WebFetch(https://www.parl.ca/*)", - "WebSearch" - ] - end - - def system_prompt - AgentPrompts::SYSTEM_PROMPT + api_context - end - - def api_context - url = ENV.fetch("RAILS_API_URL", "http://localhost:3000") - key = Rails.application.credentials.dig(:agent, :api_key) || ENV["AGENT_API_KEY"] - "\n\n## Rails API Connection\nBase URL: `#{url}`\nAuth header: `Authorization: Bearer #{key}`\nSee CLAUDE.md for endpoint details and enum values.\n" - end - - def agent_env(commitment_id: nil, entry_id: nil) - { - "CLAUDE_CODE_OAUTH_TOKEN" => ENV["CLAUDE_CODE_OAUTH_TOKEN"], - "RAILS_API_URL" => ENV.fetch("RAILS_API_URL", "http://localhost:3000"), - "RAILS_API_KEY" => Rails.application.credentials.dig(:agent, :api_key) || ENV["AGENT_API_KEY"], - "AGENT_MODEL" => ENV.fetch("AGENT_MODEL", "claude-sonnet-5"), - "COMMITMENT_ID" => commitment_id&.to_s, - "ENTRY_ID" => entry_id&.to_s, - # Explicitly unset — subprocess must not access Rails credentials - "RAILS_MASTER_KEY" => nil, - "SECRET_KEY_BASE" => nil - }.compact - end end diff --git a/app/jobs/concerns/runs_claude_agent.rb b/app/jobs/concerns/runs_claude_agent.rb new file mode 100644 index 0000000..9657e02 --- /dev/null +++ b/app/jobs/concerns/runs_claude_agent.rb @@ -0,0 +1,124 @@ +require "net/http" +require "open3" + +# Shared plumbing for jobs that shell out to the Claude Code CLI as an agent. +# +# The agent talks back to this app over HTTP using RAILS_API_URL / RAILS_API_KEY. +# If either is misconfigured the CLI still exits 0 after explaining it is blocked, +# so `preflight_agent_api!` verifies the connection before spending an agent run. +module RunsClaudeAgent + extend ActiveSupport::Concern + + class ConfigurationError < StandardError; end + class ApiUnreachableError < StandardError; end + + ALLOWED_TOOLS = [ + "Bash(curl *)", + "WebFetch(https://*.canada.ca/*)", + "WebFetch(https://*.gc.ca/*)", + "WebFetch(https://www.parl.ca/*)", + "WebSearch" + ].freeze + + PREFLIGHT_TIMEOUT_SECONDS = 5 + + private + + def agent_dir + Rails.root.join("agent") + end + + def agent_api_url + ENV.fetch("RAILS_API_URL", "http://localhost:3000") + end + + def agent_api_key + Rails.application.credentials.dig(:agent, :api_key) || ENV["AGENT_API_KEY"] + end + + def agent_model + ENV.fetch("AGENT_MODEL", "claude-sonnet-5") + end + + # Raises if the agent could not possibly succeed: no API key, or the API host + # (the web service, when running in a separate worker container) is down. + def preflight_agent_api! + if agent_api_key.blank? + raise ConfigurationError, "Agent API key is not set (credentials.agent.api_key or AGENT_API_KEY)" + end + + uri = URI.join(agent_api_url, "/up") + response = Net::HTTP.start(uri.host, uri.port, + use_ssl: uri.scheme == "https", + open_timeout: PREFLIGHT_TIMEOUT_SECONDS, + read_timeout: PREFLIGHT_TIMEOUT_SECONDS) do |http| + http.get(uri.path) + end + + unless response.is_a?(Net::HTTPSuccess) + raise ApiUnreachableError, "Rails API at #{agent_api_url} returned #{response.code} from /up" + end + rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, IOError, OpenSSL::SSL::SSLError => e + raise ApiUnreachableError, "Rails API at #{agent_api_url} is unreachable: #{e.class}: #{e.message}" + end + + def run_agent(prompt, hook_script:, commitment_id: nil, entry_id: nil) + stream_agent( + agent_env(commitment_id: commitment_id, entry_id: entry_id), + build_cmd(prompt, hook_script: hook_script), + chdir: agent_dir.to_s + ) + end + + def stream_agent(env, cmd, chdir:) + Open3.popen2e(env, *cmd, chdir: chdir) do |stdin, output, thread| + stdin.close + output.each_line do |line| + STDERR.print(line) + STDERR.flush + end + thread.value + end + end + + def build_cmd(prompt, hook_script:) + hook_settings = { + "hooks" => { + "Stop" => [ { "hooks" => [ { "type" => "command", "command" => hook_script, "async" => true, "timeout" => 10 } ] } ] + } + }.to_json + + [ + "claude", "-p", prompt, + "--system-prompt", system_prompt, + "--allowedTools", ALLOWED_TOOLS.join(","), + "--permission-mode", "bypassPermissions", + "--model", agent_model, + "--output-format", "text", + "--settings", hook_settings + ] + end + + def system_prompt + AgentPrompts::SYSTEM_PROMPT + api_context + end + + def api_context + "\n\n## Rails API Connection\nBase URL: `#{agent_api_url}`\nAuth header: `Authorization: Bearer #{agent_api_key}`\nSee CLAUDE.md for endpoint details and enum values.\n" + end + + def agent_env(commitment_id: nil, entry_id: nil) + { + "PATH" => ENV["PATH"], + "CLAUDE_CODE_OAUTH_TOKEN" => ENV["CLAUDE_CODE_OAUTH_TOKEN"], + "RAILS_API_URL" => agent_api_url, + "RAILS_API_KEY" => agent_api_key, + "AGENT_MODEL" => agent_model, + "COMMITMENT_ID" => commitment_id&.to_s, + "ENTRY_ID" => entry_id&.to_s, + # Explicitly unset — subprocess must not access Rails credentials + "RAILS_MASTER_KEY" => nil, + "SECRET_KEY_BASE" => nil + }.compact + end +end diff --git a/app/models/criterion.rb b/app/models/criterion.rb index 4536b19..03c6843 100644 --- a/app/models/criterion.rb +++ b/app/models/criterion.rb @@ -10,12 +10,14 @@ class Criterion < ApplicationRecord failure: 3 } + # scopes: false — `not_met` would collide with the `not_met` negative scope + # Rails auto-generates for `met`. Predicate methods (met?, not_met?) are kept. enum :status, { not_assessed: 0, met: 1, not_met: 3, no_longer_applicable: 4 - } + }, scopes: false validates :category, presence: true validates :description, presence: true diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f3246bb..d3dd284 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -19,6 +19,7 @@ services: - DEFUDDLE_API_URL - APPSIGNAL_PUSH_API_KEY - CLAUDE_CODE_OAUTH_TOKEN + - AGENT_API_KEY depends_on: worker: condition: service_started @@ -48,4 +49,8 @@ services: - DEFUDDLE_API_URL - APPSIGNAL_PUSH_API_KEY - CLAUDE_CODE_OAUTH_TOKEN + - AGENT_API_KEY + - AGENT_MODEL + # Agent subprocesses call back into the API; localhost:3000 is not the web container. + - RAILS_API_URL=${RAILS_API_URL:-http://web:3000} restart: unless-stopped diff --git a/test/jobs/agent_evaluate_commitment_job_test.rb b/test/jobs/agent_evaluate_commitment_job_test.rb new file mode 100644 index 0000000..e6d625b --- /dev/null +++ b/test/jobs/agent_evaluate_commitment_job_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class AgentEvaluateCommitmentJobTest < ActiveJob::TestCase + setup do + @commitment = commitments(:defence_spending) + @original_url = ENV["RAILS_API_URL"] + @original_key = ENV["AGENT_API_KEY"] + end + + teardown do + ENV["RAILS_API_URL"] = @original_url + ENV["AGENT_API_KEY"] = @original_key + end + + test "schedules a retry instead of launching the agent when no API key is configured" do + ENV.delete("AGENT_API_KEY") + + Rails.application.credentials.stub(:dig, nil) do + error = perform_and_capture_retry_error(AgentEvaluateCommitmentJob.new(@commitment)) + + assert_instance_of RunsClaudeAgent::ConfigurationError, error + assert_match(/API key is not set/, error.message) + end + end + + test "schedules a retry instead of launching the agent when the Rails API is unreachable" do + ENV["AGENT_API_KEY"] = "test-key" + ENV["RAILS_API_URL"] = "http://127.0.0.1:1" + + error = perform_and_capture_retry_error(AgentEvaluateCommitmentJob.new(@commitment)) + + assert_instance_of RunsClaudeAgent::ApiUnreachableError, error + assert_match(%r{http://127\.0\.0\.1:1 is unreachable}, error.message) + end + + private + + # retry_on rescues the failure and re-enqueues the job; the error is only + # visible through the enqueue_retry instrumentation event. + def perform_and_capture_retry_error(job) + error = nil + callback = ->(*, payload) { error = payload[:error] } + + ActiveSupport::Notifications.subscribed(callback, "enqueue_retry.active_job") do + job.perform_now + end + + assert_enqueued_jobs 1, only: job.class + error + end +end diff --git a/test/jobs/agent_process_entry_job_test.rb b/test/jobs/agent_process_entry_job_test.rb new file mode 100644 index 0000000..9108ca4 --- /dev/null +++ b/test/jobs/agent_process_entry_job_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class AgentProcessEntryJobTest < ActiveJob::TestCase + setup do + @entry = entries(:entry_one) + @original_url = ENV["RAILS_API_URL"] + @original_key = ENV["AGENT_API_KEY"] + end + + teardown do + ENV["RAILS_API_URL"] = @original_url + ENV["AGENT_API_KEY"] = @original_key + end + + test "schedules a retry instead of launching the agent when the Rails API is unreachable" do + ENV["AGENT_API_KEY"] = "test-key" + ENV["RAILS_API_URL"] = "http://127.0.0.1:1" + + error = nil + callback = ->(*, payload) { error = payload[:error] } + + ActiveSupport::Notifications.subscribed(callback, "enqueue_retry.active_job") do + AgentProcessEntryJob.perform_now(@entry) + end + + assert_enqueued_jobs 1, only: AgentProcessEntryJob + assert_instance_of RunsClaudeAgent::ApiUnreachableError, error + end +end diff --git a/test/models/criterion_test.rb b/test/models/criterion_test.rb index ebe3c29..d849e91 100644 --- a/test/models/criterion_test.rb +++ b/test/models/criterion_test.rb @@ -29,6 +29,15 @@ class CriterionTest < ActiveSupport::TestCase criterion.status = :met assert criterion.met? + + criterion.status = :not_met + assert criterion.not_met? + assert_not criterion.met? + end + + test "status enum does not generate scopes that collide with not_met" do + assert_not Criterion.respond_to?(:not_met) + assert_not Criterion.respond_to?(:met) end test "belongs to commitment" do