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.
- checksums.yaml +7 -0
- data/.rubocop.yml +10 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +21 -0
- data/README.md +360 -0
- data/Rakefile +16 -0
- data/TODO.md +105 -0
- data/agent_c.gemspec +38 -0
- data/docs/batch.md +503 -0
- data/docs/chat-methods.md +156 -0
- data/docs/cost-reporting.md +86 -0
- data/docs/pipeline-tips-and-tricks.md +453 -0
- data/docs/session-configuration.md +274 -0
- data/docs/testing.md +747 -0
- data/docs/tools.md +103 -0
- data/docs/versioned-store.md +840 -0
- data/lib/agent_c/agent/chat.rb +211 -0
- data/lib/agent_c/agent/chat_response.rb +38 -0
- data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
- data/lib/agent_c/batch.rb +102 -0
- data/lib/agent_c/configs/repo.rb +90 -0
- data/lib/agent_c/context.rb +56 -0
- data/lib/agent_c/costs/data.rb +39 -0
- data/lib/agent_c/costs/report.rb +219 -0
- data/lib/agent_c/db/store.rb +162 -0
- data/lib/agent_c/errors.rb +19 -0
- data/lib/agent_c/pipeline.rb +152 -0
- data/lib/agent_c/pipelines/agent.rb +219 -0
- data/lib/agent_c/processor.rb +98 -0
- data/lib/agent_c/prompts.yml +53 -0
- data/lib/agent_c/schema.rb +71 -0
- data/lib/agent_c/session.rb +206 -0
- data/lib/agent_c/store.rb +72 -0
- data/lib/agent_c/test_helpers.rb +173 -0
- data/lib/agent_c/tools/dir_glob.rb +46 -0
- data/lib/agent_c/tools/edit_file.rb +114 -0
- data/lib/agent_c/tools/file_metadata.rb +43 -0
- data/lib/agent_c/tools/git_status.rb +30 -0
- data/lib/agent_c/tools/grep.rb +119 -0
- data/lib/agent_c/tools/paths.rb +36 -0
- data/lib/agent_c/tools/read_file.rb +94 -0
- data/lib/agent_c/tools/run_rails_test.rb +87 -0
- data/lib/agent_c/tools.rb +61 -0
- data/lib/agent_c/utils/git.rb +87 -0
- data/lib/agent_c/utils/shell.rb +58 -0
- data/lib/agent_c/version.rb +5 -0
- data/lib/agent_c.rb +32 -0
- data/lib/versioned_store/base.rb +314 -0
- data/lib/versioned_store/config.rb +26 -0
- data/lib/versioned_store/stores/schema.rb +127 -0
- data/lib/versioned_store/version.rb +5 -0
- data/lib/versioned_store.rb +5 -0
- data/template/Gemfile +9 -0
- data/template/Gemfile.lock +152 -0
- data/template/README.md +61 -0
- data/template/Rakefile +50 -0
- data/template/bin/rake +27 -0
- data/template/lib/autoload.rb +10 -0
- data/template/lib/config.rb +59 -0
- data/template/lib/pipeline.rb +19 -0
- data/template/lib/prompts.yml +57 -0
- data/template/lib/store.rb +17 -0
- data/template/test/pipeline_test.rb +221 -0
- data/template/test/test_helper.rb +18 -0
- 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
|
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
|