fix: merge develop and address PR review comments

This commit is contained in:
Tanmay Deep Sharma
2026-03-18 15:31:59 +05:30
1814 changed files with 54531 additions and 10714 deletions
+8 -6
View File
@@ -36,7 +36,7 @@ class Captain::BaseTaskService
"#{endpoint}/v1"
end
def make_api_call(model:, messages:, tools: [])
def make_api_call(model:, messages:, schema: nil, tools: [])
# Community edition prerequisite checks
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
@@ -46,7 +46,7 @@ class Captain::BaseTaskService
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
response = send(instrumentation_method, instrumentation_params) do
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
execute_ruby_llm_request(model: model, messages: messages, schema: schema, tools: tools)
end
return response unless build_follow_up_context? && response[:message].present?
@@ -54,9 +54,9 @@ class Captain::BaseTaskService
response.merge(follow_up_context: build_follow_up_context(messages, response))
end
def execute_ruby_llm_request(model:, messages:, tools: [])
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
chat = build_chat(context, model: model, messages: messages, tools: tools)
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
conversation_messages = messages.reject { |m| m[:role] == 'system' }
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
@@ -69,10 +69,11 @@ class Captain::BaseTaskService
{ error: e.message, request_messages: messages }
end
def build_chat(context, model:, messages:, tools: [])
def build_chat(context, model:, messages:, schema: nil, tools: [])
chat = context.chat(model: model)
system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg
chat.with_schema(schema) if schema
if tools.any?
tools.each { |tool| chat = chat.with_tool(tool) }
@@ -131,7 +132,8 @@ class Captain::BaseTaskService
.reorder('id desc')
.each do |message|
content = message.content_for_llm
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
next if content.blank?
break if character_count + content.length > TOKEN_LIMIT
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
character_count += content.length
-2
View File
@@ -4,7 +4,6 @@ module Current
thread_mattr_accessor :account_user
thread_mattr_accessor :executed_by
thread_mattr_accessor :contact
thread_mattr_accessor :captain_resolve_reason
def self.reset
Current.user = nil
@@ -12,6 +11,5 @@ module Current
Current.account_user = nil
Current.executed_by = nil
Current.contact = nil
Current.captain_resolve_reason = nil
end
end
+2
View File
@@ -21,6 +21,8 @@ module Events::Types
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
CONVERSATION_OPENED = 'conversation.opened'
CONVERSATION_RESOLVED = 'conversation.resolved'
CONVERSATION_CAPTAIN_INFERENCE_RESOLVED = 'conversation.captain_inference_resolved'
CONVERSATION_CAPTAIN_INFERENCE_HANDOFF = 'conversation.captain_inference_handoff'
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
+4
View File
@@ -14,4 +14,8 @@ class GlobalConfigService
GlobalConfig.clear_cache
i.value
end
def self.account_signup_enabled?
load('ENABLE_ACCOUNT_SIGNUP', 'false').to_s != 'false'
end
end
@@ -0,0 +1,121 @@
class Integrations::Linear::AccessTokenService
TOKEN_URL = 'https://api.linear.app/oauth/token'.freeze
MIGRATE_OLD_TOKEN_URL = 'https://api.linear.app/oauth/migrate_old_token'.freeze
TOKEN_EXPIRY_BUFFER = 1.minute
pattr_initialize [:hook!]
def access_token
return hook.access_token if token_valid?
return refresh_access_token if refresh_token.present?
return migrate_legacy_token if migration_applicable?
hook.access_token
end
private
def refresh_access_token
response = HTTParty.post(
TOKEN_URL,
headers: url_encoded_headers,
body: {
grant_type: 'refresh_token',
refresh_token: refresh_token,
client_id: client_id,
client_secret: client_secret
}
)
return fallback_access_token unless response.success?
persist_tokens(response.parsed_response)
hook.access_token
rescue StandardError => e
Rails.logger.error("Linear token refresh failed for hook #{hook.id}: #{e.message}")
fallback_access_token
end
def migrate_legacy_token
response = HTTParty.post(
MIGRATE_OLD_TOKEN_URL,
headers: url_encoded_headers,
body: {
access_token: hook.access_token,
client_id: client_id,
client_secret: client_secret
}
)
return fallback_access_token unless response.success?
persist_tokens(response.parsed_response)
hook.access_token
rescue StandardError => e
Rails.logger.error("Linear legacy token migration failed for hook #{hook.id}: #{e.message}")
fallback_access_token
end
def persist_tokens(token_data)
raise ArgumentError, 'Missing access token in Linear token response' if token_data['access_token'].blank?
current_settings = hook_settings
updated_settings = current_settings.merge(
token_type: token_data['token_type'] || current_settings[:token_type],
expires_in: token_data['expires_in'] || current_settings[:expires_in],
expires_on: expires_on(token_data['expires_in']),
scope: token_data['scope'] || current_settings[:scope],
refresh_token: token_data['refresh_token'] || current_settings[:refresh_token]
).compact
hook.update!(
access_token: token_data['access_token'],
settings: updated_settings
)
end
def token_valid?
expiry = hook_settings[:expires_on]
return false if expiry.blank?
Time.zone.parse(expiry).utc > (Time.current.utc + TOKEN_EXPIRY_BUFFER)
rescue StandardError
false
end
def migration_applicable?
hook_settings[:token_type].present?
end
def refresh_token
hook_settings[:refresh_token]
end
def hook_settings
hook.settings.to_h.with_indifferent_access
end
def expires_on(expires_in)
return hook_settings[:expires_on] if expires_in.blank?
(Time.current.utc + expires_in.to_i.seconds).to_s
end
def url_encoded_headers
{ 'Content-Type' => 'application/x-www-form-urlencoded' }
end
def client_id
GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
end
def client_secret
GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
end
def fallback_access_token
hook.reload.access_token
rescue StandardError
hook.access_token
end
end
+5 -1
View File
@@ -77,6 +77,10 @@ class Integrations::Linear::ProcessorService
end
def linear_client
@linear_client ||= Linear.new(linear_hook.access_token)
@linear_client ||= Linear.new(linear_access_token)
end
def linear_access_token
@linear_access_token ||= Integrations::Linear::AccessTokenService.new(hook: linear_hook).access_token
end
end
@@ -66,7 +66,7 @@ module Integrations::LlmInstrumentationCompletionHelpers
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.is_a?(String) ? message : message.to_json)
end
def set_usage_metrics(span, result)
+7 -2
View File
@@ -3,8 +3,9 @@ class Linear
REVOKE_URL = 'https://api.linear.app/oauth/revoke'.freeze
PRIORITY_LEVELS = (0..4).to_a
def initialize(access_token)
def initialize(access_token, refresh_token: nil)
@access_token = access_token
@refresh_token = refresh_token
raise ArgumentError, 'Missing Credentials' if access_token.blank?
end
@@ -79,9 +80,13 @@ class Linear
end
def revoke_token
token = @refresh_token.presence || @access_token
token_type_hint = @refresh_token.present? ? 'refresh_token' : 'access_token'
response = HTTParty.post(
REVOKE_URL,
headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }
headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
body: { token: token, token_type_hint: token_type_hint }
)
response.success?
end
+5 -2
View File
@@ -1,6 +1,8 @@
class OnlineStatusTracker
# NOTE: You can customise the environment variable to keep your agents/contacts as online for longer
PRESENCE_DURATION = ENV.fetch('PRESENCE_DURATION', 20).to_i.seconds
# Widget pings every 60s, so contacts need a longer presence window
CONTACT_PRESENCE_DURATION = ENV.fetch('CONTACT_PRESENCE_DURATION', 90).to_i.seconds
# presence : sorted set with timestamp as the score & object id as value
@@ -11,7 +13,8 @@ class OnlineStatusTracker
def self.get_presence(account_id, obj_type, obj_id)
connected_time = ::Redis::Alfred.zscore(presence_key(account_id, obj_type), obj_id)
connected_time && connected_time > (Time.zone.now - PRESENCE_DURATION).to_i
duration = obj_type == 'Contact' ? CONTACT_PRESENCE_DURATION : PRESENCE_DURATION
connected_time && connected_time > (Time.zone.now - duration).to_i
end
def self.presence_key(account_id, type)
@@ -39,7 +42,7 @@ class OnlineStatusTracker
end
def self.get_available_contact_ids(account_id)
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
range_start = (Time.zone.now - CONTACT_PRESENCE_DURATION).to_i
# exclusive minimum score is specified by prefixing (
# we are clearing old records because this could clogg up the sorted set
::Redis::Alfred.zremrangebyscore(presence_key(account_id, 'Contact'), '-inf', "(#{range_start}")
+81
View File
@@ -0,0 +1,81 @@
# Migrate max_assignment_limit to Agent Capacity Policies
#
# Converts legacy per-inbox max_assignment_limit settings into
# AgentCapacityPolicy records used by Assignment V2.
#
# Usage Examples:
# # Migrate a single account
# ACCOUNT_ID=1 bundle exec rake assignment_v2:migrate
#
# # Migrate all accounts in the installation
# bundle exec rake assignment_v2:migrate
#
# Parameters:
# ACCOUNT_ID: (optional) ID of the account to migrate. If omitted, migrates all accounts.
#
# rubocop:disable Metrics/BlockLength
namespace :assignment_v2 do
desc 'Migrate max_assignment_limit inbox settings to agent capacity policies'
task migrate: :environment do
int_max = (2**31) - 1
policy_name = 'Auto Assignment Capacity'
account_id = ENV.fetch('ACCOUNT_ID', nil)
accounts = account_id.present? ? Account.where(id: account_id) : Account.all
if account_id.blank?
print 'No ACCOUNT_ID specified. This will migrate ALL accounts. Continue? [y/N] '
abort 'Aborted.' unless $stdin.gets.chomp.casecmp('y').zero?
end
if account_id.present? && accounts.empty?
puts "Error: Account with ID #{account_id} not found"
exit(1)
end
total = accounts.count
puts "Migrating assignment policies for #{total} account(s)..."
puts "Started at: #{Time.current}"
migrated = 0
skipped = 0
errored = 0
accounts.find_each do |account|
inboxes_with_limit = account.inboxes.where("auto_assignment_config->>'max_assignment_limit' ~ '[1-9]'")
if inboxes_with_limit.empty?
skipped += 1
puts " [#{migrated + skipped + errored}/#{total}] Account #{account.id} — skipped (no inboxes with limit)"
next
end
ActiveRecord::Base.transaction do
policy = AgentCapacityPolicy.find_or_create_by!(account: account, name: policy_name) do |p|
p.description = 'Migrated from inbox settings'
end
inboxes_with_limit.each do |inbox|
next if InboxCapacityLimit.exists?(agent_capacity_policy_id: policy.id, inbox_id: inbox.id)
limit = [inbox.auto_assignment_config['max_assignment_limit'].to_i, int_max].min
InboxCapacityLimit.create!(agent_capacity_policy: policy, inbox: inbox, conversation_limit: limit)
end
member_user_ids = InboxMember.where(inbox_id: inboxes_with_limit.select(:id)).distinct.pluck(:user_id)
account.account_users
.where(user_id: member_user_ids, agent_capacity_policy_id: nil)
.find_each { |au| au.update!(agent_capacity_policy_id: policy.id) }
end
migrated += 1
puts " [#{migrated + skipped + errored}/#{total}] Account #{account.id} — migrated"
rescue StandardError => e
errored += 1
puts " [#{migrated + skipped + errored}/#{total}] Account #{account.id} — error: #{e.message}"
end
puts "\nDone! Migrated: #{migrated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}"
end
end
# rubocop:enable Metrics/BlockLength
-12
View File
@@ -1,12 +0,0 @@
namespace :companies do
desc 'Backfill companies from existing contact email domains'
task backfill: :environment do
puts 'Starting company backfill migration...'
puts 'This will process all accounts and create companies from contact email domains.'
puts 'The job will run in the background via Sidekiq'
puts ''
Migration::CompanyBackfillJob.perform_later
puts 'Company backfill job has been enqueued.'
puts 'Monitor progress in logs or Sidekiq dashboard.'
end
end