agent_c 2.71828

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +10 -0
  3. data/.ruby-version +1 -0
  4. data/CLAUDE.md +21 -0
  5. data/README.md +360 -0
  6. data/Rakefile +16 -0
  7. data/TODO.md +12 -0
  8. data/agent_c.gemspec +38 -0
  9. data/docs/chat-methods.md +157 -0
  10. data/docs/cost-reporting.md +86 -0
  11. data/docs/pipeline-tips-and-tricks.md +71 -0
  12. data/docs/session-configuration.md +274 -0
  13. data/docs/testing.md +747 -0
  14. data/docs/tools.md +103 -0
  15. data/docs/versioned-store.md +840 -0
  16. data/lib/agent_c/agent/chat.rb +211 -0
  17. data/lib/agent_c/agent/chat_response.rb +32 -0
  18. data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
  19. data/lib/agent_c/batch.rb +102 -0
  20. data/lib/agent_c/configs/repo.rb +90 -0
  21. data/lib/agent_c/context.rb +56 -0
  22. data/lib/agent_c/costs/data.rb +39 -0
  23. data/lib/agent_c/costs/report.rb +219 -0
  24. data/lib/agent_c/db/store.rb +162 -0
  25. data/lib/agent_c/errors.rb +19 -0
  26. data/lib/agent_c/pipeline.rb +188 -0
  27. data/lib/agent_c/processor.rb +98 -0
  28. data/lib/agent_c/prompts.yml +53 -0
  29. data/lib/agent_c/schema.rb +85 -0
  30. data/lib/agent_c/session.rb +207 -0
  31. data/lib/agent_c/store.rb +72 -0
  32. data/lib/agent_c/test_helpers.rb +173 -0
  33. data/lib/agent_c/tools/dir_glob.rb +46 -0
  34. data/lib/agent_c/tools/edit_file.rb +112 -0
  35. data/lib/agent_c/tools/file_metadata.rb +43 -0
  36. data/lib/agent_c/tools/grep.rb +119 -0
  37. data/lib/agent_c/tools/paths.rb +36 -0
  38. data/lib/agent_c/tools/read_file.rb +94 -0
  39. data/lib/agent_c/tools/run_rails_test.rb +87 -0
  40. data/lib/agent_c/tools.rb +60 -0
  41. data/lib/agent_c/utils/git.rb +75 -0
  42. data/lib/agent_c/utils/shell.rb +58 -0
  43. data/lib/agent_c/version.rb +5 -0
  44. data/lib/agent_c.rb +32 -0
  45. data/lib/versioned_store/base.rb +314 -0
  46. data/lib/versioned_store/config.rb +26 -0
  47. data/lib/versioned_store/stores/schema.rb +127 -0
  48. data/lib/versioned_store/version.rb +5 -0
  49. data/lib/versioned_store.rb +5 -0
  50. data/template/Gemfile +9 -0
  51. data/template/Gemfile.lock +152 -0
  52. data/template/README.md +61 -0
  53. data/template/Rakefile +50 -0
  54. data/template/bin/rake +27 -0
  55. data/template/lib/autoload.rb +10 -0
  56. data/template/lib/config.rb +59 -0
  57. data/template/lib/pipeline.rb +19 -0
  58. data/template/lib/prompts.yml +57 -0
  59. data/template/lib/store.rb +17 -0
  60. data/template/test/pipeline_test.rb +221 -0
  61. data/template/test/test_helper.rb +18 -0
  62. metadata +191 -0
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Costs
5
+ class Report
6
+
7
+ # Anthropic models Price per 1,000 input tokens Price per 1,000 output tokens Price per 1,000 input tokens (batch) Price per 1,000 output tokens (batch) Price per 1,000 input tokens (5m cache write) Price per 1,000 input tokens (1h cache write) Price per 1,000 input tokens (cache read)
8
+ # Claude Sonnet 4.5 $0.003 $0.015 $0.0015 $0.0075 $0.00375 $0.006 $0.0003
9
+ # Claude Sonnet 4.5 - Long Context $0.006 $0.0225 $0.003 $0.01125 $0.0075 $0.012 $0.0006
10
+
11
+ PRICING = {
12
+ normal: {
13
+ input: 3.0, # $0.003 per 1k tokens
14
+ output: 15.0, # $0.015 per 1k tokens
15
+ cached_input: 0.3, # $0.0003 per 1k tokens (cache read)
16
+ cache_creation: 3.75 # $0.00375 per 1k tokens (5m cache write)
17
+ },
18
+ long: {
19
+ input: 6.0, # $0.006 per 1k tokens
20
+ output: 22.5, # $0.0225 per 1k tokens
21
+ cached_input: 0.6, # $0.0006 per 1k tokens (cache read)
22
+ cache_creation: 7.5 # $0.0075 per 1k tokens (5m cache write)
23
+ }
24
+ }.freeze
25
+
26
+ LONG_CONTEXT_THRESHOLD = 200_000 # tokens
27
+
28
+ def self.call(...)
29
+ new(...).call
30
+ end
31
+
32
+ def initialize(agent_store:, project: nil, run_id: nil, out: $stdout)
33
+ @agent_store = agent_store
34
+ @project = project
35
+ @run_id = run_id
36
+ @out = out
37
+ @calculator = Calculator.new
38
+ @printer = Printer.new(out: @out)
39
+ end
40
+
41
+ def call
42
+ # Gather all computations based on hierarchy
43
+ computations = []
44
+
45
+ # Always compute totals for all projects
46
+ all_messages = fetch_all_messages
47
+ if all_messages.any?
48
+ computations << {
49
+ label: "All Projects",
50
+ stats: @calculator.calculate(all_messages),
51
+ messages: all_messages
52
+ }
53
+ end
54
+
55
+ # If project is specified, add project-level computation
56
+ if @project
57
+ project_messages = fetch_project_messages
58
+ if project_messages.any?
59
+ computations << {
60
+ label: "Project: #{@project}",
61
+ stats: @calculator.calculate(project_messages),
62
+ messages: project_messages
63
+ }
64
+ end
65
+
66
+ # If run_id is also specified, add run-level computation
67
+ if @run_id
68
+ run_messages = fetch_run_messages
69
+ if run_messages.any?
70
+ computations << {
71
+ label: "Project: #{@project}, Run ID: #{@run_id}",
72
+ stats: @calculator.calculate(run_messages),
73
+ messages: run_messages
74
+ }
75
+ end
76
+ end
77
+ end
78
+
79
+ if computations.empty?
80
+ @out.puts "No messages found" if @out
81
+ return []
82
+ end
83
+
84
+ # Print to output if provided
85
+ if @out
86
+ @printer.print_hierarchical_report(computations)
87
+ end
88
+
89
+ # Return structured data
90
+ computations
91
+ end
92
+
93
+ private
94
+
95
+ def fetch_all_messages
96
+ @agent_store.message
97
+ .joins(:chat)
98
+ .includes(:model, :chat)
99
+ end
100
+
101
+ def fetch_project_messages
102
+ @agent_store.message
103
+ .joins(:chat)
104
+ .where(chats: { project: @project })
105
+ .includes(:model, :chat)
106
+ end
107
+
108
+ def fetch_run_messages
109
+ @agent_store.message
110
+ .joins(:chat)
111
+ .where(chats: { project: @project, run_id: @run_id })
112
+ .includes(:model, :chat)
113
+ end
114
+
115
+ # Calculator class for computing costs
116
+ class Calculator
117
+ def calculate(messages)
118
+ stats = {
119
+ input_tokens: 0,
120
+ output_tokens: 0,
121
+ cached_tokens: 0,
122
+ cache_creation_tokens: 0,
123
+ input_cost: 0.0,
124
+ output_cost: 0.0,
125
+ cached_cost: 0.0,
126
+ cache_creation_cost: 0.0,
127
+ total_cost: 0.0,
128
+ message_count: messages.count
129
+ }
130
+
131
+ messages.each do |message|
132
+ stats[:input_tokens] += message.input_tokens || 0
133
+ stats[:output_tokens] += message.output_tokens || 0
134
+ stats[:cached_tokens] += message.cached_tokens || 0
135
+ stats[:cache_creation_tokens] += message.cache_creation_tokens || 0
136
+
137
+ # Determine which pricing tier to use based on total token count
138
+ total_message_tokens = (message.input_tokens || 0) +
139
+ (message.cached_tokens || 0) +
140
+ (message.cache_creation_tokens || 0)
141
+ pricing = total_message_tokens > LONG_CONTEXT_THRESHOLD ? PRICING[:long] : PRICING[:normal]
142
+
143
+ # Calculate cost using appropriate pricing tier
144
+ # Input tokens cost
145
+ if message.input_tokens && message.input_tokens > 0
146
+ cost = (message.input_tokens / 1_000_000.0) * pricing[:input]
147
+ stats[:input_cost] += cost
148
+ stats[:total_cost] += cost
149
+ end
150
+
151
+ # Output tokens cost
152
+ if message.output_tokens && message.output_tokens > 0
153
+ cost = (message.output_tokens / 1_000_000.0) * pricing[:output]
154
+ stats[:output_cost] += cost
155
+ stats[:total_cost] += cost
156
+ end
157
+
158
+ # Cached tokens cost (cache read)
159
+ if message.cached_tokens && message.cached_tokens > 0
160
+ cost = (message.cached_tokens / 1_000_000.0) * pricing[:cached_input]
161
+ stats[:cached_cost] += cost
162
+ stats[:total_cost] += cost
163
+ end
164
+
165
+ # Cache creation tokens cost
166
+ if message.cache_creation_tokens && message.cache_creation_tokens > 0
167
+ cost = (message.cache_creation_tokens / 1_000_000.0) * pricing[:cache_creation]
168
+ stats[:cache_creation_cost] += cost
169
+ stats[:total_cost] += cost
170
+ end
171
+ end
172
+
173
+ stats
174
+ end
175
+ end
176
+
177
+ # Printer class for formatting output
178
+ class Printer
179
+ def initialize(out: $stdout)
180
+ @out = out
181
+ end
182
+
183
+ def print_hierarchical_report(computations)
184
+ computations.each_with_index do |computation, index|
185
+ print_section(computation[:label], computation[:stats], computation[:messages])
186
+ @out.puts "" if index < computations.length - 1
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ def print_section(label, stats, messages)
193
+ total_tokens = stats[:input_tokens] + stats[:output_tokens] + stats[:cached_tokens] + stats[:cache_creation_tokens]
194
+
195
+ @out.puts <<~REPORT
196
+ ============================================================
197
+ Cost Report: #{label}
198
+ ============================================================
199
+ Messages: #{stats[:message_count]}
200
+ Chats: #{messages.map(&:chat).uniq.count}
201
+ ------------------------------------------------------------
202
+ Token Usage:
203
+ Input tokens: #{format_number(stats[:input_tokens]).rjust(15)} $#{format('%.4f', stats[:input_cost]).rjust(8)}
204
+ Output tokens: #{format_number(stats[:output_tokens]).rjust(15)} $#{format('%.4f', stats[:output_cost]).rjust(8)}
205
+ Cached tokens: #{format_number(stats[:cached_tokens]).rjust(15)} $#{format('%.4f', stats[:cached_cost]).rjust(8)}
206
+ Cache creation tokens: #{format_number(stats[:cache_creation_tokens]).rjust(15)} $#{format('%.4f', stats[:cache_creation_cost]).rjust(8)}
207
+ ------------------------------------------------------------
208
+ Total: #{format_number(total_tokens).rjust(15)} $#{format('%.4f', stats[:total_cost]).rjust(8)}
209
+ ============================================================
210
+ REPORT
211
+ end
212
+
213
+ def format_number(num)
214
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "ruby_llm/active_record/acts_as"
5
+
6
+ module AgentC
7
+ module Db
8
+ class Store < VersionedStore::Base
9
+ record(:model) do
10
+ schema :models do |t|
11
+ t.string :model_id, null: false
12
+ t.string :name, null: false
13
+ t.string :provider, null: false
14
+ t.string :family
15
+ t.datetime :model_created_at
16
+ t.integer :context_window
17
+ t.integer :max_output_tokens
18
+ t.date :knowledge_cutoff
19
+ t.json :modalities, default: {}
20
+ t.json :capabilities, default: []
21
+ t.json :pricing, default: {}
22
+ t.json :metadata, default: {}
23
+ t.timestamps
24
+
25
+ t.index [:provider, :model_id], unique: true
26
+ t.index :provider
27
+ t.index :family
28
+ end
29
+
30
+ include RubyLLM::ActiveRecord::ActsAs
31
+ acts_as_model chats: :chats, chat_class: class_name(:chat)
32
+ end
33
+
34
+ record(:chat) do
35
+ schema :chats do |t|
36
+ t.references :model, foreign_key: true
37
+ t.string :project
38
+ t.string :run_id
39
+ t.timestamps
40
+ end
41
+
42
+ include RubyLLM::ActiveRecord::ActsAs
43
+
44
+ acts_as_chat(
45
+ messages: :messages,
46
+ message_class: class_name(:message),
47
+ messages_foreign_key: :chat_id,
48
+ model: :model,
49
+ model_class: class_name(:model),
50
+ model_foreign_key: :model_id
51
+ )
52
+
53
+ belongs_to(
54
+ :model,
55
+ class_name: class_name(:model),
56
+ required: false
57
+ )
58
+
59
+ validates :model, presence: true
60
+
61
+ def messages_hash
62
+ messages
63
+ .map {
64
+ hash = _1.to_llm.to_h
65
+
66
+ if hash.key?(:tool_calls)
67
+ hash[:tool_calls] = hash.fetch(:tool_calls).values.map(&:to_h)
68
+ end
69
+
70
+ if hash.key?(:content) && !hash[:content].is_a?(String)
71
+ hash[:content] = hash[:content].to_h
72
+ end
73
+
74
+ hash
75
+ }
76
+ end
77
+ end
78
+
79
+ record(:message) do
80
+ schema :messages do |t|
81
+ t.references :chat, null: false, foreign_key: true
82
+ t.references :model, foreign_key: true
83
+ t.references :tool_call, foreign_key: true
84
+ t.string :role, null: false
85
+ t.text :content
86
+ t.json :content_raw
87
+ t.integer :input_tokens
88
+ t.integer :output_tokens
89
+ t.integer :cached_tokens
90
+ t.integer :cache_creation_tokens
91
+ t.timestamps
92
+
93
+ t.index :role
94
+ end
95
+
96
+ include RubyLLM::ActiveRecord::ActsAs
97
+
98
+ acts_as_message(
99
+ chat: :chat,
100
+ chat_class: class_name(:chat),
101
+ chat_foreign_key: :chat_id,
102
+ tool_calls: :tool_calls,
103
+ tool_call_class: class_name(:tool_call),
104
+ tool_calls_foreign_key: :message_id,
105
+ model: :model,
106
+ model_class: class_name(:model),
107
+ model_foreign_key: :model_id
108
+ )
109
+
110
+ belongs_to(
111
+ :chat,
112
+ class_name: class_name(:chat),
113
+ required: false
114
+ )
115
+
116
+ belongs_to(
117
+ :model,
118
+ class_name: class_name(:model),
119
+ required: false
120
+ )
121
+
122
+ belongs_to(
123
+ :tool_call,
124
+ class_name: class_name(:tool_call),
125
+ required: false
126
+ )
127
+
128
+ validates :role, presence: true
129
+ validates :chat, presence: true
130
+ end
131
+
132
+ record(:tool_call) do
133
+ schema :tool_calls do |t|
134
+ t.references :message, null: false, foreign_key: true
135
+ t.string :tool_call_id, null: false
136
+ t.string :name, null: false
137
+ t.json :arguments, default: {}
138
+ t.timestamps
139
+
140
+ t.index :tool_call_id, unique: true
141
+ t.index :name
142
+ end
143
+
144
+ include RubyLLM::ActiveRecord::ActsAs
145
+
146
+ acts_as_tool_call(
147
+ message: :message,
148
+ message_class: class_name(:message),
149
+ message_foreign_key: :message_id,
150
+ result: :result,
151
+ result_class: class_name(:message)
152
+ )
153
+
154
+ belongs_to(
155
+ :message,
156
+ class_name: class_name(:message),
157
+ required: false
158
+ )
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Errors
5
+ class Base < StandardError
6
+ end
7
+
8
+ class AbortCostExceeded < Base
9
+ attr_reader :cost_type, :current_cost, :threshold
10
+
11
+ def initialize(cost_type:, current_cost:, threshold:)
12
+ @cost_type = cost_type
13
+ @current_cost = current_cost
14
+ @threshold = threshold
15
+ super("Abort: #{cost_type} cost $#{current_cost.round(2)} exceeds threshold $#{threshold.round(2)}")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ class Pipeline
5
+ def self.call(...)
6
+ new(...).tap(&:call)
7
+ end
8
+
9
+ attr_reader :session, :task
10
+ def initialize(
11
+ session:,
12
+ task:,
13
+ git: ->(dir) { Utils::Git.new(dir) }
14
+ )
15
+ @session = session
16
+ @task = task
17
+ @git = git
18
+ end
19
+
20
+ Step = Data.define(:name, :block)
21
+
22
+ class << self
23
+ def on_failures
24
+ @on_failures ||= []
25
+ end
26
+
27
+ def steps
28
+ @steps ||= []
29
+ end
30
+
31
+ def on_failure(&block)
32
+ self.on_failures << block
33
+ end
34
+
35
+ def step(name, &block)
36
+ self.steps << Step.new(name:, block:)
37
+ end
38
+
39
+ def agent_step(name, **params, &block)
40
+ step(name) do
41
+ resolved_params = (
42
+ if block
43
+ instance_exec(&block)
44
+ elsif params.empty?
45
+ i18n_attributes = (
46
+ if record.respond_to?(:i18n_attributes)
47
+ record.i18n_attributes
48
+ else
49
+ record.attributes
50
+ end
51
+ )
52
+
53
+ {
54
+ tool_args: {
55
+ workspace_dir: workspace.dir,
56
+ env: workspace.env,
57
+ },
58
+ cached_prompt: I18n.t("#{name}.cached_prompts"),
59
+ prompt: I18n.t("#{name}.prompt", **i18n_attributes.symbolize_keys),
60
+ tools: I18n.t("#{name}.tools"),
61
+ schema: -> {
62
+ next unless I18n.exists?("#{name}.response_schema")
63
+
64
+ I18n.t("#{name}.response_schema").each do |name, spec|
65
+ extra = spec.except(:required, :description, :type)
66
+
67
+ if extra.key?(:of)
68
+ extra[:of] = extra[:of]&.to_sym
69
+ end
70
+
71
+ send(
72
+ spec.fetch(:type, "string"),
73
+ name,
74
+ required: spec.fetch(:required, true),
75
+ description: spec.fetch(:description),
76
+ **extra
77
+ )
78
+ end
79
+ }
80
+ }
81
+ else
82
+ i18n_attributes = (
83
+ if record.respond_to?(:i18n_attributes)
84
+ record.i18n_attributes
85
+ else
86
+ record.attributes
87
+ end
88
+ )
89
+
90
+ {
91
+ tool_args: {
92
+ workspace_dir: workspace.dir,
93
+ env: workspace.env,
94
+ }
95
+ }.tap { |hash|
96
+ if params.key?(:prompt_key)
97
+ hash[:prompt] = I18n.t(params[:prompt_key], **i18n_attributes.symbolize_keys)
98
+ end
99
+
100
+ if params.key?(:cached_prompt_keys)
101
+ hash[:cached_prompt] = params[:cached_prompt_keys].map { I18n.t(_1) }
102
+ end
103
+ }.merge(params.except(:cached_prompt_keys, :prompt_key))
104
+ end
105
+ )
106
+
107
+ result = session.prompt(
108
+ on_chat_created: -> (id) { task.chat_ids << id},
109
+ **resolved_params
110
+ )
111
+
112
+ if result.success?
113
+ task.record.update!(result.data)
114
+ else
115
+ task.fail!(result.error_message)
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ def call
122
+ raise "Task.workspace is nil" unless task.workspace
123
+
124
+ log("start")
125
+
126
+ self.class.steps.each do |step|
127
+ break if task.failed?
128
+
129
+
130
+ store.transaction do
131
+ log_prefix = "step: '#{step.name}'"
132
+
133
+ if task.completed_steps.include?(step.name.to_s)
134
+ log("#{log_prefix} already completed, skipping")
135
+ next
136
+ end
137
+
138
+ log("#{log_prefix} start")
139
+
140
+ instance_exec(&step.block)
141
+
142
+ if task.failed?
143
+ log("#{log_prefix} failed, executing on_failures")
144
+ self.class.on_failures.each { instance_exec(&_1)}
145
+ else
146
+ log("#{log_prefix} done")
147
+ task.completed_steps << step.name.to_s
148
+ end
149
+ end
150
+ end
151
+
152
+ store.transaction do
153
+ log("done")
154
+ task.done! unless task.failed?
155
+ end
156
+ rescue => e
157
+ store.transaction do
158
+ log("Exception raised, running on_failures")
159
+ task.fail!(["#{e.class.name}:#{e.message}", e.backtrace].join("\n"))
160
+ self.class.on_failures.each { instance_exec(&_1) }
161
+ end
162
+ end
163
+
164
+ def workspace
165
+ task.workspace
166
+ end
167
+
168
+ def record
169
+ task.record
170
+ end
171
+
172
+ def store
173
+ task.store
174
+ end
175
+
176
+ def repo
177
+ @repo ||= @git.call(workspace.dir)
178
+ end
179
+
180
+ def log(msg)
181
+ logger.info("task: #{task.id}: #{msg}")
182
+ end
183
+
184
+ def logger
185
+ session.logger
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ class Processor
5
+ Handler = Data.define(:task, :handler) do
6
+ def call
7
+ handler.call(task)
8
+ if task.pending?
9
+ raise "Task Pending error"
10
+ end
11
+ end
12
+ end
13
+
14
+ attr_reader :context, :handlers
15
+ def initialize(context:, handlers:)
16
+ @context = context
17
+ @handlers = handlers.transform_keys(&:to_s)
18
+ end
19
+
20
+ def add_task(record, handler)
21
+ raise ArgumentError.new("invalid handler") unless handlers.include?(handler.to_s)
22
+
23
+ store.task.find_or_create_by!(record:, handler:)
24
+ end
25
+
26
+ def call(&)
27
+ raise "must provide at least one workspace" if workspace_count == 0
28
+
29
+ if workspace_count == 1
30
+ call_synchronous(context.workspaces.first, &)
31
+ else
32
+ call_asynchronous(&)
33
+ end
34
+ end
35
+
36
+ def abort!
37
+ @abort = true
38
+ end
39
+
40
+ def abort?
41
+ @abort
42
+ end
43
+
44
+ private
45
+
46
+ def call_asynchronous(&)
47
+ error = nil
48
+
49
+ Async { |task|
50
+ semaphore = Async::Semaphore.new(workspace_count)
51
+
52
+ context.workspaces.map { |workspace|
53
+ semaphore.async do
54
+ call_synchronous(workspace, &)
55
+ rescue => e
56
+ abort!
57
+ error = e
58
+ end
59
+ }
60
+
61
+ }.wait
62
+
63
+ raise error if error
64
+ end
65
+
66
+ def call_synchronous(workspace)
67
+ while(handler = next_handler(workspace))
68
+ handler.call
69
+ yield if block_given? # allow the invoker to do work inbetween handler calls
70
+ end
71
+ end
72
+
73
+ def next_handler(workspace)
74
+ return nil if abort?
75
+
76
+ task = (
77
+ store
78
+ .task
79
+ .where("workspace_id = ? OR workspace_id IS NULL", workspace.id)
80
+ .order("created_at ASC")
81
+ .find_by(status: :pending)
82
+ )
83
+
84
+ if task
85
+ task.update!(workspace:) unless task.workspace
86
+ Handler.new(task:, handler: handlers.fetch(task.handler))
87
+ end
88
+ end
89
+
90
+ def workspace_count
91
+ @workspace_count ||= context.workspaces.count
92
+ end
93
+
94
+ def store
95
+ context.store
96
+ end
97
+ end
98
+ end