agent_c 2.9

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 (65) 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 +105 -0
  8. data/agent_c.gemspec +38 -0
  9. data/docs/batch.md +503 -0
  10. data/docs/chat-methods.md +156 -0
  11. data/docs/cost-reporting.md +86 -0
  12. data/docs/pipeline-tips-and-tricks.md +453 -0
  13. data/docs/session-configuration.md +274 -0
  14. data/docs/testing.md +747 -0
  15. data/docs/tools.md +103 -0
  16. data/docs/versioned-store.md +840 -0
  17. data/lib/agent_c/agent/chat.rb +211 -0
  18. data/lib/agent_c/agent/chat_response.rb +38 -0
  19. data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
  20. data/lib/agent_c/batch.rb +102 -0
  21. data/lib/agent_c/configs/repo.rb +90 -0
  22. data/lib/agent_c/context.rb +56 -0
  23. data/lib/agent_c/costs/data.rb +39 -0
  24. data/lib/agent_c/costs/report.rb +219 -0
  25. data/lib/agent_c/db/store.rb +162 -0
  26. data/lib/agent_c/errors.rb +19 -0
  27. data/lib/agent_c/pipeline.rb +152 -0
  28. data/lib/agent_c/pipelines/agent.rb +219 -0
  29. data/lib/agent_c/processor.rb +98 -0
  30. data/lib/agent_c/prompts.yml +53 -0
  31. data/lib/agent_c/schema.rb +71 -0
  32. data/lib/agent_c/session.rb +206 -0
  33. data/lib/agent_c/store.rb +72 -0
  34. data/lib/agent_c/test_helpers.rb +173 -0
  35. data/lib/agent_c/tools/dir_glob.rb +46 -0
  36. data/lib/agent_c/tools/edit_file.rb +114 -0
  37. data/lib/agent_c/tools/file_metadata.rb +43 -0
  38. data/lib/agent_c/tools/git_status.rb +30 -0
  39. data/lib/agent_c/tools/grep.rb +119 -0
  40. data/lib/agent_c/tools/paths.rb +36 -0
  41. data/lib/agent_c/tools/read_file.rb +94 -0
  42. data/lib/agent_c/tools/run_rails_test.rb +87 -0
  43. data/lib/agent_c/tools.rb +61 -0
  44. data/lib/agent_c/utils/git.rb +87 -0
  45. data/lib/agent_c/utils/shell.rb +58 -0
  46. data/lib/agent_c/version.rb +5 -0
  47. data/lib/agent_c.rb +32 -0
  48. data/lib/versioned_store/base.rb +314 -0
  49. data/lib/versioned_store/config.rb +26 -0
  50. data/lib/versioned_store/stores/schema.rb +127 -0
  51. data/lib/versioned_store/version.rb +5 -0
  52. data/lib/versioned_store.rb +5 -0
  53. data/template/Gemfile +9 -0
  54. data/template/Gemfile.lock +152 -0
  55. data/template/README.md +61 -0
  56. data/template/Rakefile +50 -0
  57. data/template/bin/rake +27 -0
  58. data/template/lib/autoload.rb +10 -0
  59. data/template/lib/config.rb +59 -0
  60. data/template/lib/pipeline.rb +19 -0
  61. data/template/lib/prompts.yml +57 -0
  62. data/template/lib/store.rb +17 -0
  63. data/template/test/pipeline_test.rb +221 -0
  64. data/template/test/test_helper.rb +18 -0
  65. metadata +194 -0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module AgentC
6
+ module Tools
7
+ class RunRailsTest < RubyLLM::Tool
8
+ description "Runs a minitest rails test using bin/rails test {path} --name={name_of_test_method}"
9
+
10
+ params do
11
+ string(
12
+ :path,
13
+ description: "Path to file. Must be a child of current directory.",
14
+ required: true
15
+ )
16
+ string(
17
+ :test_method_name,
18
+ description: "The name of the specific test method to run",
19
+ required: false
20
+ )
21
+ boolean(
22
+ :disable_spring,
23
+ description: <<~TXT,
24
+ Disable spring if the errors are weird and you want to make sure
25
+ it's not a spring issue. Prefer to use spring unless you are
26
+ encountering weird behavior.
27
+ TXT
28
+ required: false
29
+ )
30
+ end
31
+
32
+ attr_reader :workspace_dir, :env
33
+ def initialize(
34
+ workspace_dir: nil,
35
+ env: {},
36
+ **
37
+ )
38
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
39
+ @env = env
40
+ @workspace_dir = workspace_dir
41
+ end
42
+
43
+ def execute(path:, test_method_name: nil, disable_spring: false, **params)
44
+
45
+ # Spring hangs, need to timeout unresponsive shells
46
+ disable_spring = true
47
+
48
+ if params.any?
49
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
50
+ end
51
+
52
+ unless Paths.allowed?(workspace_dir, path)
53
+ return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
54
+ end
55
+
56
+ workspace_path = Paths.relative_to_dir(workspace_dir, path)
57
+
58
+ env_string = env.is_a?(Hash) ? env.map { |k, v| "#{k}=#{Shellwords.escape(v)}"}.join(" ") : env
59
+
60
+ env_string += " DISABLE_SPRING=1" if disable_spring
61
+
62
+ cmd = <<~TXT.chomp
63
+ cd #{workspace_dir} && \
64
+ #{env_string} bundle exec rails test #{path} #{test_method_name && "--name='#{test_method_name}'"}
65
+ TXT
66
+
67
+ lines = []
68
+ result = nil
69
+ Bundler.with_unbundled_env do
70
+ result = shell.run(cmd) do |stream, line|
71
+ lines << "[#{stream}] #{line}"
72
+ end
73
+ end
74
+
75
+ return <<~TXT
76
+ Command exited #{result.success? ? "successfully" : "with non-zero exit code"}
77
+ ---
78
+ #{lines.join("\n")}
79
+ TXT
80
+ end
81
+
82
+ def shell
83
+ AgentC::Utils::Shell
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Tools
5
+ NAMES = {
6
+ read_file: ReadFile,
7
+ edit_file: EditFile,
8
+ grep: Grep,
9
+ file_metadata: FileMetadata,
10
+ dir_glob: DirGlob,
11
+ run_rails_test: RunRailsTest,
12
+ git_status: GitStatus
13
+ }
14
+
15
+ def self.all(**params)
16
+ NAMES.values.map { _1.new(**params) }
17
+ end
18
+
19
+ def self.resolve(value:, available_tools:, args:, workspace_dir:)
20
+
21
+ # ensure any args passed have a
22
+ # workspace_dir.
23
+ resolved_args = (
24
+ if args.key?(:workspace_dir)
25
+ args
26
+ else
27
+ args.merge(workspace_dir:)
28
+ end
29
+ )
30
+
31
+ # If they passed a tool instance, nothing to do
32
+ if value.is_a?(RubyLLM::Tool)
33
+ return value
34
+ elsif value.is_a?(Symbol) || value.is_a?(String)
35
+ # They passed the tool name
36
+ # we must initialize it with
37
+ # the standard args
38
+ tool_name = value.to_sym
39
+ unless available_tools.key?(tool_name)
40
+ raise ArgumentError, <<~TXT
41
+ Unknown tool name: #{value.inspect}.
42
+ If you wish to use a custom tool you must configure
43
+ it by passing `extra_tools` to the Session.
44
+ TXT
45
+ end
46
+
47
+ klass_or_instance = available_tools.fetch(tool_name)
48
+
49
+ if klass_or_instance.is_a?(RubyLLM::Tool)
50
+ klass_or_instance
51
+ else
52
+ klass_or_instance.new(**resolved_args)
53
+ end
54
+ elsif value.is_a?(Class) && value.ancestors.include?(RubyLLM::Tool)
55
+ value.new(**resolved_args)
56
+ else
57
+ raise ArgumentError, "unknown tool specified: #{value.inspect}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module AgentC
6
+ module Utils
7
+ # Git utility class for managing Git operations in worktrees
8
+ class Git
9
+ attr_reader :dir
10
+
11
+ def initialize(dir)
12
+ @dir = dir
13
+ end
14
+
15
+ def create_worktree(worktree_dir:, branch:, revision:)
16
+ # Prune any stale worktrees first
17
+ shell.run!("cd #{dir} && git worktree prune")
18
+
19
+ # Remove worktree at dir if it exists (don't fail if it doesn't exist)
20
+ shell.run!("cd #{dir} && (git worktree remove #{Shellwords.escape(worktree_dir)} --force 2>/dev/null || true)")
21
+
22
+ shell.run!(
23
+ <<~TXT
24
+ cd #{dir} && \
25
+ git worktree add \
26
+ -B #{Shellwords.escape(branch)} \
27
+ #{Shellwords.escape(worktree_dir)} \
28
+ #{Shellwords.escape(revision)}
29
+ TXT
30
+ )
31
+ end
32
+
33
+ def diff
34
+ # --intent-to-add will ensure untracked files are included
35
+ # in the diff.
36
+ shell.run!(
37
+ <<~TXT
38
+ cd #{dir} && \
39
+ git add --all --intent-to-add && \
40
+ git diff --relative
41
+ TXT
42
+ )
43
+ end
44
+
45
+ def last_revision
46
+ shell.run!("cd #{dir} && git rev-parse @").strip
47
+ end
48
+
49
+ def commit_all(message)
50
+ shell.run!("cd #{dir} && git add --all && git commit --no-gpg-sign -m #{Shellwords.escape(message)}")
51
+ last_revision
52
+ end
53
+
54
+ def fixup_commit(revision)
55
+ shell.run!("cd #{dir} && git add --all && git commit --no-gpg-sign --fixup #{revision}")
56
+ last_revision
57
+ end
58
+
59
+ def reset_hard_all
60
+ shell.run!("cd #{dir} && git add --all && git reset --hard")
61
+ end
62
+
63
+ def status
64
+ shell.run!("cd #{dir} && git status")
65
+ end
66
+
67
+ def clean?
68
+ !uncommitted_changes?
69
+ end
70
+
71
+ def uncommitted_changes?
72
+ # Check for any changes including untracked files
73
+ # Returns true if there are uncommitted changes (staged, unstaged, or untracked)
74
+ status = shell.run!("cd #{dir} && git status --porcelain")
75
+ !status.strip.empty?
76
+ end
77
+
78
+ private
79
+
80
+ def shell
81
+ AgentC::Utils::Shell
82
+ end
83
+ end
84
+
85
+ # TODO: Add more utility classes here as needed
86
+ end
87
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "open3"
5
+
6
+ module AgentC
7
+ module Utils
8
+ class Shell
9
+ Error = Class.new(StandardError)
10
+ class << self
11
+ Result = Struct.new(:command, :success, :stdout, :stderr, keyword_init: true) do
12
+ def success?
13
+ success
14
+ end
15
+ end
16
+
17
+ def run!(...)
18
+ # only returns stdout just because.
19
+ run(...)
20
+ .tap { raise "command failed: \n#{_1.inspect}" unless _1.success? }
21
+ .stdout
22
+ end
23
+
24
+ def run(command)
25
+ Open3.popen3(command) do |_in, stdout, stderr, wait_thr|
26
+ process_stdout = []
27
+ stdout_thr = Thread.new do
28
+ while line = stdout.gets&.chomp
29
+ yield(:stdout, line) if block_given?
30
+ process_stdout << line
31
+ end
32
+ end
33
+
34
+ process_stderr = []
35
+ stderr_thr = Thread.new do
36
+ while line = stderr.gets&.chomp
37
+ yield(:stderr, line) if block_given?
38
+ process_stderr << line
39
+ end
40
+ end
41
+
42
+ [
43
+ stderr_thr,
44
+ stdout_thr,
45
+ ].each(&:join)
46
+
47
+ Result.new(
48
+ command: command,
49
+ success: wait_thr.value.success?,
50
+ stdout: process_stdout.join("\n"),
51
+ stderr: process_stderr.join("\n"),
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ VERSION = "2.9"
5
+ end
data/lib/agent_c.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "active_support/all"
5
+
6
+ # this shows an annyoing warning
7
+ begin
8
+ old_stderr = $stderr
9
+ $stderr = StringIO.new
10
+ require "async"
11
+ require "async/semaphore"
12
+ $stderr = old_stderr
13
+ ensure
14
+ end
15
+
16
+ require "zeitwerk"
17
+ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
18
+ loader.setup
19
+
20
+ # Configure i18n how I like it:
21
+
22
+ require "i18n"
23
+ MissingTranslation = Class.new(StandardError)
24
+ I18n.singleton_class.prepend(Module.new do
25
+ def t(*a, **p)
26
+ super(*a, __force_exception_raising__: true, **p)
27
+ end
28
+ end)
29
+ I18n.exception_handler = ->(_, _, key, _) { raise MissingTranslation.new(key.inspect) }
30
+ I18n.load_path << File.join(__dir__, "./agent_c/prompts.yml")
31
+
32
+ module AgentC; end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module VersionedStore
6
+ class Base
7
+ class << self
8
+ def inherited(subclass)
9
+ super
10
+ # If parent has a schema, duplicate it for the child
11
+ # Otherwise create a new empty schema
12
+ parent_schema = @schema
13
+ if parent_schema
14
+ subclass.instance_variable_set(:@schema, parent_schema.dup)
15
+ else
16
+ subclass.instance_variable_set(:@schema, Stores::Schema.new)
17
+ end
18
+ end
19
+
20
+ def schema
21
+ @schema ||= Stores::Schema.new
22
+ end
23
+
24
+ def record(name, table: nil, &block)
25
+ schema.record(name, table: table, &block)
26
+ end
27
+
28
+ def migrate(version = nil, &block)
29
+ if version.nil?
30
+ schema.migrate(&block)
31
+ else
32
+ schema.migrate(version, &block)
33
+ end
34
+ end
35
+ end
36
+
37
+ attr_reader :schema, :version, :config
38
+ def initialize(version: :root, **config)
39
+ @schema = self.class.schema
40
+ @schema.add_table_migrations!
41
+ @version = version
42
+ @config = Config.new(**config)
43
+ @klasses = {}
44
+
45
+ if File.exist?(@config.db_filename)
46
+ @config&.logger&.info("using existing store at: #{@config.db_filename}")
47
+ else
48
+ @config&.logger&.info("creating store at: #{@config.db_filename}")
49
+ end
50
+
51
+
52
+ # Define accessor methods for each record type
53
+ schema.records.each do |name, spec|
54
+ singleton_class.define_method(name) { class_for(spec) }
55
+ end
56
+
57
+ # eagerly construct classes
58
+ schema.records.each_value { class_for(_1) }
59
+
60
+ # Run post-initialization hooks for setting up associations
61
+ schema.post_init_hooks.each { |hook| hook.call(self) }
62
+ end
63
+
64
+ def transaction(&)
65
+ base_class.transaction(&)
66
+ end
67
+
68
+ def dir
69
+ @config.dir
70
+ end
71
+
72
+ def versions
73
+ return [self] unless root?
74
+
75
+ version_files = Dir.glob(File.join(versions_dir, "*.sqlite3")).sort
76
+ version_files.map do |file|
77
+ version_num = File.basename(file, ".sqlite3").to_i
78
+ self.class.new(version: version_num, **config.to_h)
79
+ end
80
+ end
81
+
82
+ def restore(name = nil)
83
+ if name
84
+ # Named snapshot restore
85
+ raise "Can only restore from root store" unless root?
86
+
87
+ snapshot_path = File.join(snapshots_dir, "#{name}.sqlite3")
88
+ raise "Snapshot '#{name}' does not exist" unless File.exist?(snapshot_path)
89
+
90
+ # Copy the snapshot to the main database
91
+ main_db = File.join(dir, config.db_filename)
92
+ FileUtils.cp(snapshot_path, main_db)
93
+
94
+ # Create a new version snapshot of the restored state
95
+ timestamp = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
96
+ version_file = File.join(versions_dir, "#{timestamp}.sqlite3")
97
+ FileUtils.mkdir_p(File.dirname(version_file))
98
+ FileUtils.cp(main_db, version_file)
99
+
100
+ # Return a new root store for the restored state
101
+ self.class.new(version:, **config.to_h)
102
+ else
103
+ # Version-based restore (existing behavior)
104
+ raise "Can only restore from a version snapshot, not from root" if root?
105
+
106
+ # Copy the version database to the main database
107
+ main_db = File.join(dir, config.db_filename)
108
+ FileUtils.cp(db_path, main_db)
109
+
110
+ # Renumber versions: keep all versions up to and including this one,
111
+ # then create a new version snapshot for the restore
112
+ version_files = Dir.glob(File.join(versions_dir, "*.sqlite3")).sort
113
+ version_files.each do |file|
114
+ file_version = File.basename(file, ".sqlite3").to_i
115
+ if file_version > version
116
+ FileUtils.rm(file)
117
+ end
118
+ end
119
+
120
+ # Create a new version snapshot of the restored state
121
+ new_version_num = version + 1
122
+ version_file = File.join(versions_dir, "#{new_version_num}.sqlite3")
123
+ FileUtils.cp(main_db, version_file)
124
+
125
+ # Return a new root store for the restored state
126
+ self.class.new(version: :root, **config.to_h)
127
+ end
128
+ end
129
+
130
+ def snapshot(name)
131
+ raise "Can only create snapshots from root store" unless root?
132
+
133
+ # Create snapshots directory if it doesn't exist
134
+ FileUtils.mkdir_p(snapshots_dir)
135
+
136
+ # Copy current database to named snapshot
137
+ snapshot_path = File.join(snapshots_dir, "#{name}.sqlite3")
138
+ FileUtils.cp(db_path, snapshot_path)
139
+ end
140
+
141
+ private
142
+
143
+ def schema_root_mod_name
144
+ @schema_root_mod_name ||+ "SchemaRoot_#{schema.object_id}_#{object_id}"
145
+ end
146
+
147
+ def schema_root_mod
148
+ @schema_root_mod ||= (
149
+ if Base.const_defined?(schema_root_mod_name)
150
+ Base.const_get(schema_root_mod_name)
151
+ else
152
+ Module.new.tap { Base.const_set(schema_root_mod_name, _1) }
153
+ end
154
+ )
155
+ end
156
+
157
+ def base_class
158
+ @base_class ||= (
159
+ me = self
160
+ klass = Class.new(ActiveRecord::Base) do
161
+ define_singleton_method(:transaction_mutex) do
162
+ @transaction_mutex ||= Mutex.new
163
+ end
164
+
165
+ define_singleton_method(:class_name) do |name|
166
+ "#{me.send(:schema_root_mod)}::Record_#{name}"
167
+ end
168
+
169
+ define_singleton_method(:store) do
170
+ me
171
+ end
172
+
173
+ define_method(:store) do
174
+ self.class.store
175
+ end
176
+
177
+ define_singleton_method(:transaction) do |**options, &block|
178
+ # If already in a transaction, just call super without creating a backup
179
+ return super(**options, &block) if connection.transaction_open?
180
+
181
+ transaction_mutex.synchronize do
182
+ result = super(**options, &block)
183
+
184
+ if me.config.versioned
185
+ timestamp = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
186
+ backup_path = File.join(me.send(:versions_dir), "#{timestamp}.sqlite3")
187
+ FileUtils.mkdir_p(File.dirname(backup_path))
188
+ FileUtils.cp(me.send(:db_path), backup_path)
189
+ end
190
+
191
+ result
192
+ end
193
+ end
194
+ end
195
+
196
+ # ActiveRecord doesn't let this be anonymous?
197
+ schema_root_mod.const_set(
198
+ "Base",
199
+ klass
200
+ )
201
+
202
+ klass.abstract_class = true
203
+ klass.establish_connection(
204
+ adapter: 'sqlite3',
205
+ database: db_path
206
+ )
207
+
208
+ # Configure SQLite to use DELETE journal mode instead of WAL
209
+ # This avoids creating -wal and -shm files
210
+ klass.connection.execute("PRAGMA journal_mode=DELETE")
211
+ klass.connection.execute("PRAGMA locking_mode=NORMAL")
212
+
213
+ # Only run migrations for the root store, not for version snapshots
214
+ if root? && schema.migrations.any?
215
+ # Ensure schema_migrations table exists
216
+ unless klass.connection.table_exists?(:schema_migrations)
217
+ klass.connection.create_table(:schema_migrations, id: false) do |t|
218
+ t.string :version, null: false
219
+ end
220
+ klass.connection.add_index(:schema_migrations, :version, unique: true)
221
+ end
222
+
223
+ # Run any migrations that haven't been run yet
224
+ schema.migrations.each do |migration|
225
+ version = migration.version.to_s
226
+
227
+ # Check if migration has already been run using quote method for SQL safety
228
+ existing = klass.connection.select_value(
229
+ "SELECT 1 FROM schema_migrations WHERE version = #{klass.connection.quote(version)} LIMIT 1"
230
+ )
231
+ next if existing
232
+
233
+ # Run the migration
234
+ klass.connection.instance_exec(&migration.block)
235
+
236
+ # Record that this migration has been run
237
+ klass.connection.execute(
238
+ "INSERT INTO schema_migrations (version) VALUES (#{klass.connection.quote(version)})"
239
+ )
240
+ end
241
+ end
242
+
243
+ klass
244
+ )
245
+ end
246
+
247
+ def class_for(spec)
248
+ @klasses[spec.name] ||= (
249
+ store = self
250
+ Class.new(base_class) do
251
+ self.table_name = spec.table
252
+
253
+ # Execute all blocks if present, but define a schema method to ignore schema calls
254
+ if spec.blocks && !spec.blocks.empty?
255
+ define_singleton_method(:schema) { |*args, &blk| }
256
+ spec.blocks.each do |blk|
257
+ class_exec(&blk) if blk
258
+ end
259
+ singleton_class.remove_method(:schema) rescue nil
260
+ end
261
+
262
+ # Make all records readonly if this is a version snapshot
263
+ if not store.send(:root?)
264
+ define_method(:readonly?) { true }
265
+ end
266
+
267
+ # Override polymorphic_name to return simple string name (both instance and class methods)
268
+ simple_name = spec.name.to_s
269
+
270
+ define_method(:polymorphic_name) do
271
+ simple_name
272
+ end
273
+
274
+ define_singleton_method(:polymorphic_name) do
275
+ simple_name
276
+ end
277
+
278
+ # Override polymorphic_class_for to resolve simple names back to classes
279
+ define_singleton_method(:polymorphic_class_for) do |name|
280
+ store.send(:class_for, store.schema.records.fetch(name.to_sym))
281
+ end
282
+ end
283
+ ).tap {
284
+ schema_root_mod.const_set("Record_#{spec.name}", _1)
285
+ }
286
+ end
287
+
288
+ def db_prefix
289
+ @db_prefix ||= config.db_filename.sub(/\.sqlite3$/, "")
290
+ end
291
+
292
+ def versions_dir
293
+ @versions_dir ||= File.join(dir, "#{db_prefix}_versions")
294
+ end
295
+
296
+ def snapshots_dir
297
+ @snapshots_dir ||= File.join(dir, "#{db_prefix}_snapshots")
298
+ end
299
+
300
+ def db_path
301
+ @db_path ||= (
302
+ if root?
303
+ File.join(dir, config.db_filename)
304
+ else
305
+ File.join(versions_dir, "#{version}.sqlite3")
306
+ end
307
+ )
308
+ end
309
+
310
+ def root?
311
+ version == :root
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VersionedStore
4
+ Config = Data.define(:dir, :db_filename, :logger, :versioned) do
5
+ def initialize(dir: nil, path: nil, logger: nil, versioned: true, db_filename: nil)
6
+ raise ArgumentError, "Must provide either dir: or path:, not both" if dir && path
7
+ raise ArgumentError, "Must provide either dir: or path:" unless dir || path
8
+
9
+ db_filename ||= (
10
+ if path
11
+ dir = File.dirname(path)
12
+ db_filename = File.basename(path)
13
+ else
14
+ db_filename = "db.sqlite3"
15
+ end
16
+ )
17
+
18
+ super(
19
+ dir:,
20
+ db_filename:,
21
+ logger:,
22
+ versioned:
23
+ )
24
+ end
25
+ end
26
+ end