Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
81 changes: 6 additions & 75 deletions app/jobs/agent_evaluate_commitment_job.rb
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,89 +14,20 @@ 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})"
end

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
75 changes: 5 additions & 70 deletions app/jobs/agent_process_entry_job.rb
Original file line number Diff line number Diff line change
@@ -1,90 +1,25 @@
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})"
end

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
124 changes: 124 additions & 0 deletions app/jobs/concerns/runs_claude_agent.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion app/models/criterion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading
Loading