glancer 1.0.0
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/.github/workflows/ci.yml +96 -0
- data/.rubocop.yml +54 -0
- data/CHANGELOG.md +88 -0
- data/CLAUDE.md +115 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +354 -0
- data/app/assets/config/glancer_manifest.js +1 -0
- data/app/assets/javascripts/glancer/application.js +15 -0
- data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
- data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
- data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
- data/app/assets/stylesheets/glancer/application.css +350 -0
- data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
- data/app/assets/stylesheets/glancer/list.css +31 -0
- data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
- data/app/assets/stylesheets/glancer/table.css +97 -0
- data/app/controllers/glancer/application_controller.rb +33 -0
- data/app/controllers/glancer/chats_controller.rb +49 -0
- data/app/controllers/glancer/messages_controller.rb +144 -0
- data/app/controllers/glancer/schema_controller.rb +29 -0
- data/app/controllers/glancer/settings_controller.rb +23 -0
- data/app/helpers/glancer/application_helper.rb +17 -0
- data/app/jobs/glancer/application_job.rb +6 -0
- data/app/jobs/glancer/process_message_job.rb +38 -0
- data/app/models/glancer/audit.rb +12 -0
- data/app/models/glancer/chat.rb +8 -0
- data/app/models/glancer/code_version.rb +12 -0
- data/app/models/glancer/embedding.rb +6 -0
- data/app/models/glancer/message.rb +25 -0
- data/app/models/glancer/setting.rb +23 -0
- data/app/models/glancer/sql_version.rb +6 -0
- data/app/views/glancer/_data/_importmap.json.erb +7 -0
- data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
- data/app/views/glancer/chats/_show.html.erb +52 -0
- data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
- data/app/views/glancer/chats/index.html.erb +10 -0
- data/app/views/glancer/chats/show.html.erb +1 -0
- data/app/views/glancer/messages/_data_table.html.erb +268 -0
- data/app/views/glancer/messages/_execution_error.html.erb +26 -0
- data/app/views/glancer/messages/_form.html.erb +93 -0
- data/app/views/glancer/messages/_message.html.erb +206 -0
- data/app/views/glancer/messages/_message_info.html.erb +176 -0
- data/app/views/glancer/messages/_temp_form.html.erb +100 -0
- data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
- data/app/views/glancer/schema/show.html.erb +123 -0
- data/app/views/glancer/settings/show.html.erb +306 -0
- data/app/views/glancer/shared/_icons.html.erb +126 -0
- data/app/views/layouts/glancer/application.html.erb +234 -0
- data/config/locales/glancer.en.yml +90 -0
- data/config/locales/glancer.es.yml +90 -0
- data/config/locales/glancer.pt-BR.yml +90 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
- data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
- data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
- data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
- data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
- data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
- data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
- data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
- data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
- data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
- data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
- data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
- data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
- data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
- data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
- data/lib/generators/glancer/install/install_generator.rb +74 -0
- data/lib/generators/glancer/install/templates/glancer.rb +227 -0
- data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
- data/lib/glancer/async_runner.rb +50 -0
- data/lib/glancer/chart_analyzer.rb +230 -0
- data/lib/glancer/configuration.rb +372 -0
- data/lib/glancer/engine.rb +90 -0
- data/lib/glancer/indexer/context_indexer.rb +58 -0
- data/lib/glancer/indexer/model_indexer.rb +64 -0
- data/lib/glancer/indexer/schema_indexer.rb +171 -0
- data/lib/glancer/indexer.rb +50 -0
- data/lib/glancer/retriever.rb +114 -0
- data/lib/glancer/utils/logger.rb +83 -0
- data/lib/glancer/utils/markdown_helper.rb +56 -0
- data/lib/glancer/utils/result_formatter.rb +25 -0
- data/lib/glancer/utils/table_stats.rb +18 -0
- data/lib/glancer/utils/transaction.rb +59 -0
- data/lib/glancer/version.rb +5 -0
- data/lib/glancer/workflow/ar_executor.rb +104 -0
- data/lib/glancer/workflow/ar_extractor.rb +25 -0
- data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
- data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
- data/lib/glancer/workflow/builder.rb +129 -0
- data/lib/glancer/workflow/cache.rb +55 -0
- data/lib/glancer/workflow/executor.rb +72 -0
- data/lib/glancer/workflow/llm.rb +123 -0
- data/lib/glancer/workflow/prompt_builder.rb +143 -0
- data/lib/glancer/workflow/query_enricher.rb +117 -0
- data/lib/glancer/workflow/sql_extractor.rb +42 -0
- data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
- data/lib/glancer/workflow/sql_validator.rb +67 -0
- data/lib/glancer/workflow.rb +158 -0
- data/lib/glancer.rb +50 -0
- data/lib/tasks/glancer/tailwind.rake +8 -0
- data/lib/tasks/glancer.rake +99 -0
- data/spec/glancer_spec.rb +62 -0
- data/spec/lib/glancer/async_runner_spec.rb +133 -0
- data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
- data/spec/lib/glancer/configuration_spec.rb +858 -0
- data/spec/lib/glancer/engine_spec.rb +209 -0
- data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
- data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
- data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
- data/spec/lib/glancer/indexer_spec.rb +95 -0
- data/spec/lib/glancer/retriever_spec.rb +179 -0
- data/spec/lib/glancer/utils/logger_spec.rb +85 -0
- data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
- data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
- data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
- data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
- data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
- data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
- data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
- data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
- data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
- data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
- data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
- data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
- data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
- data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
- data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
- data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
- data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
- data/spec/lib/glancer/workflow_spec.rb +308 -0
- data/spec/models/glancer/audit_spec.rb +82 -0
- data/spec/models/glancer/chat_spec.rb +60 -0
- data/spec/models/glancer/code_version_spec.rb +71 -0
- data/spec/models/glancer/embedding_spec.rb +73 -0
- data/spec/models/glancer/message_spec.rb +144 -0
- data/spec/models/glancer/setting_spec.rb +88 -0
- data/spec/models/glancer/sql_version_spec.rb +4 -0
- data/spec/spec_helper.rb +128 -0
- data/spec/support/schema.rb +55 -0
- metadata +255 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::ARSanitizer do
|
|
6
|
+
describe ".ensure_safe!" do
|
|
7
|
+
context "with safe read-only expressions" do
|
|
8
|
+
it "passes User.all" do
|
|
9
|
+
expect { described_class.ensure_safe!("User.all") }.not_to raise_error
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "passes a where chain" do
|
|
13
|
+
expect { described_class.ensure_safe!("User.where(active: true).count") }.not_to raise_error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "passes joins + select" do
|
|
17
|
+
code = "Order.joins(:items).select('orders.id, count(items.id) as item_count').group('orders.id')"
|
|
18
|
+
expect { described_class.ensure_safe!(code) }.not_to raise_error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "passes pluck" do
|
|
22
|
+
expect { described_class.ensure_safe!("User.pluck(:email)") }.not_to raise_error
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "passes aggregate methods" do
|
|
26
|
+
expect { described_class.ensure_safe!("Order.sum(:total)") }.not_to raise_error
|
|
27
|
+
expect { described_class.ensure_safe!("Order.average(:amount)") }.not_to raise_error
|
|
28
|
+
expect { described_class.ensure_safe!("Order.minimum(:created_at)") }.not_to raise_error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "does not block .update_at (column-like word in a scope)" do
|
|
32
|
+
# .updated_at is a column accessor, not the .update write method
|
|
33
|
+
expect { described_class.ensure_safe!("User.order(:updated_at)") }.not_to raise_error
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "does not block .created_at" do
|
|
37
|
+
expect { described_class.ensure_safe!("User.where('created_at > ?', 1.week.ago)") }.not_to raise_error
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
context "with destructive ActiveRecord methods" do
|
|
42
|
+
it "blocks .destroy" do
|
|
43
|
+
expect { described_class.ensure_safe!("User.first.destroy") }
|
|
44
|
+
.to raise_error(Glancer::Error, /destroy/)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "blocks .destroy_all" do
|
|
48
|
+
expect { described_class.ensure_safe!("User.where(active: false).destroy_all") }
|
|
49
|
+
.to raise_error(Glancer::Error, /destroy/)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "blocks .delete" do
|
|
53
|
+
expect { described_class.ensure_safe!("User.first.delete") }
|
|
54
|
+
.to raise_error(Glancer::Error, /delete/)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "blocks .delete_all" do
|
|
58
|
+
expect { described_class.ensure_safe!("User.delete_all") }
|
|
59
|
+
.to raise_error(Glancer::Error, /delete/)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "blocks .update (write form)" do
|
|
63
|
+
expect { described_class.ensure_safe!("User.first.update(name: 'x')") }
|
|
64
|
+
.to raise_error(Glancer::Error, /update/)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "blocks .update! (bang form)" do
|
|
68
|
+
expect { described_class.ensure_safe!("User.first.update!(name: 'x')") }
|
|
69
|
+
.to raise_error(Glancer::Error, /update/)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "blocks .update_all" do
|
|
73
|
+
expect { described_class.ensure_safe!("User.update_all(active: false)") }
|
|
74
|
+
.to raise_error(Glancer::Error, /update_all/)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "blocks .save" do
|
|
78
|
+
expect { described_class.ensure_safe!("u = User.first; u.name = 'x'; u.save") }
|
|
79
|
+
.to raise_error(Glancer::Error, /save/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "blocks .save!" do
|
|
83
|
+
expect { described_class.ensure_safe!("User.first.save!") }
|
|
84
|
+
.to raise_error(Glancer::Error, /save/)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "blocks .create" do
|
|
88
|
+
expect { described_class.ensure_safe!("User.create(name: 'Eve')") }
|
|
89
|
+
.to raise_error(Glancer::Error, /create/)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "blocks .create!" do
|
|
93
|
+
expect { described_class.ensure_safe!("User.create!(name: 'Eve')") }
|
|
94
|
+
.to raise_error(Glancer::Error, /create/)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "blocks .insert" do
|
|
98
|
+
expect { described_class.ensure_safe!("User.insert(name: 'Eve')") }
|
|
99
|
+
.to raise_error(Glancer::Error, /insert/)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "blocks .upsert" do
|
|
103
|
+
expect { described_class.ensure_safe!("User.upsert(id: 1, name: 'Eve')") }
|
|
104
|
+
.to raise_error(Glancer::Error, /upsert/)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "blocks .touch" do
|
|
108
|
+
expect { described_class.ensure_safe!("User.first.touch") }
|
|
109
|
+
.to raise_error(Glancer::Error, /touch/)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
context "with shell / OS execution" do
|
|
114
|
+
it "blocks backtick shell execution" do
|
|
115
|
+
expect { described_class.ensure_safe!("`ls -la`") }
|
|
116
|
+
.to raise_error(Glancer::Error, /shell/)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "blocks system() call" do
|
|
120
|
+
expect { described_class.ensure_safe!("system('rm -rf /')") }
|
|
121
|
+
.to raise_error(Glancer::Error, /shell/)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "blocks exec()" do
|
|
125
|
+
expect { described_class.ensure_safe!("exec('cat /etc/passwd')") }
|
|
126
|
+
.to raise_error(Glancer::Error, /shell/)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
context "with eval" do
|
|
131
|
+
it "blocks eval()" do
|
|
132
|
+
expect { described_class.ensure_safe!("eval('User.where(1=1)')") }
|
|
133
|
+
.to raise_error(Glancer::Error, /eval/)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "blocks instance_eval" do
|
|
137
|
+
expect { described_class.ensure_safe!("User.instance_eval { delete_all }") }
|
|
138
|
+
.to raise_error(Glancer::Error, /eval/)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
context "with file writes" do
|
|
143
|
+
it "blocks FileUtils" do
|
|
144
|
+
expect { described_class.ensure_safe!("FileUtils.rm_rf('/')") }
|
|
145
|
+
.to raise_error(Glancer::Error, /file write/)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "blocks File.write" do
|
|
149
|
+
expect { described_class.ensure_safe!("File.write('/etc/hosts', 'evil')") }
|
|
150
|
+
.to raise_error(Glancer::Error, /file write/)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
context "with dynamic loading" do
|
|
155
|
+
it "blocks require" do
|
|
156
|
+
expect { described_class.ensure_safe!("require 'open3'") }
|
|
157
|
+
.to raise_error(Glancer::Error, /load/)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "blocks load" do
|
|
161
|
+
expect { described_class.ensure_safe!("load '/evil.rb'") }
|
|
162
|
+
.to raise_error(Glancer::Error, /load/)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
context "when an unexpected StandardError occurs during sanitization" do
|
|
167
|
+
it "wraps the error in Glancer::Error" do
|
|
168
|
+
allow(described_class).to receive(:ensure_safe!).and_call_original
|
|
169
|
+
allow_any_instance_of(String).to receive(:match?).and_raise(StandardError, "regex engine failure")
|
|
170
|
+
expect { described_class.ensure_safe!("User.count") }
|
|
171
|
+
.to raise_error(Glancer::Error, /AR sanitization failed/)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::Builder do
|
|
6
|
+
let(:fake_response) { double("Response", content: "SELECT * FROM users") }
|
|
7
|
+
let(:fake_chat) do
|
|
8
|
+
double("Chat").tap do |c|
|
|
9
|
+
allow(c).to receive(:ask).and_return(fake_response)
|
|
10
|
+
allow(c).to receive(:with_instructions).and_return(c)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
before do
|
|
15
|
+
allow(RubyLLM).to receive(:chat).and_return(fake_chat)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# ── .build_sql ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe ".build_sql" do
|
|
21
|
+
let(:question) { "Show all users" }
|
|
22
|
+
let(:embeddings) { [] }
|
|
23
|
+
|
|
24
|
+
it "returns the SQL content from the LLM response" do
|
|
25
|
+
result = described_class.build_sql(question, embeddings)
|
|
26
|
+
expect(result).to eq("SELECT * FROM users")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "calls RubyLLM.chat with the configured provider and model" do
|
|
30
|
+
expect(RubyLLM).to receive(:chat).with(
|
|
31
|
+
hash_including(provider: Glancer.configuration.resolved_code_provider,
|
|
32
|
+
model: Glancer.configuration.resolved_code_model)
|
|
33
|
+
).and_return(fake_chat)
|
|
34
|
+
described_class.build_sql(question, embeddings)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "passes history to the prompt builder" do
|
|
38
|
+
chat = Glancer::Chat.create!(title: "C")
|
|
39
|
+
msg = Glancer::Message.create!(chat: chat, role: "user", content: "previous Q")
|
|
40
|
+
described_class.build_sql(question, embeddings, history: [msg])
|
|
41
|
+
# No error means history was processed
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "raises Glancer::Error when the LLM call fails" do
|
|
45
|
+
allow(RubyLLM).to receive(:chat).and_raise(StandardError, "network error")
|
|
46
|
+
expect { described_class.build_sql(question, embeddings) }
|
|
47
|
+
.to raise_error(Glancer::Error, /code generation failed/)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "includes recent audit examples as few-shot examples" do
|
|
51
|
+
Glancer::Audit.create!(
|
|
52
|
+
question: "How many users?",
|
|
53
|
+
code: "SELECT COUNT(*) FROM users /*glancer,run_id:abc*/",
|
|
54
|
+
adapter: Glancer.configuration.resolved_adapter.to_s,
|
|
55
|
+
run_id: SecureRandom.uuid,
|
|
56
|
+
executed_at: Time.current
|
|
57
|
+
)
|
|
58
|
+
expect(fake_chat).to receive(:ask).and_return(fake_response)
|
|
59
|
+
described_class.build_sql(question, embeddings)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ── .recent_examples ──────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe ".recent_examples" do
|
|
66
|
+
it "returns an empty array when no audits exist" do
|
|
67
|
+
expect(described_class.recent_examples).to eq([])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "returns up to 3 most recent audit pairs [question, code]" do
|
|
71
|
+
4.times do |i|
|
|
72
|
+
Glancer::Audit.create!(
|
|
73
|
+
question: "Q#{i}",
|
|
74
|
+
code: "SELECT #{i}",
|
|
75
|
+
adapter: Glancer.configuration.resolved_adapter.to_s,
|
|
76
|
+
run_id: SecureRandom.uuid,
|
|
77
|
+
executed_at: Time.current - (4 - i).seconds
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
examples = described_class.recent_examples
|
|
81
|
+
expect(examples.size).to eq(3)
|
|
82
|
+
# Most recent first
|
|
83
|
+
expect(examples.first[0]).to eq("Q3")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "only returns audits matching the current adapter" do
|
|
87
|
+
Glancer::Audit.create!(
|
|
88
|
+
question: "PostgreSQL Q",
|
|
89
|
+
code: "SELECT 1",
|
|
90
|
+
adapter: "postgres",
|
|
91
|
+
run_id: SecureRandom.uuid,
|
|
92
|
+
executed_at: Time.current
|
|
93
|
+
)
|
|
94
|
+
# SQLite adapter in tests — postgres audit should NOT appear
|
|
95
|
+
expect(described_class.recent_examples).to be_empty
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "returns [] when the Audit query raises (rescue path)" do
|
|
99
|
+
allow(Glancer::Audit).to receive(:where).and_raise(StandardError, "DB unavailable")
|
|
100
|
+
expect(described_class.recent_examples).to eq([])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "excludes audits without a question" do
|
|
104
|
+
Glancer::Audit.create!(
|
|
105
|
+
question: nil,
|
|
106
|
+
code: "SELECT 1",
|
|
107
|
+
adapter: Glancer.configuration.resolved_adapter.to_s,
|
|
108
|
+
run_id: SecureRandom.uuid,
|
|
109
|
+
executed_at: Time.current
|
|
110
|
+
)
|
|
111
|
+
expect(described_class.recent_examples).to be_empty
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ── .build_ar_code ────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe ".build_ar_code" do
|
|
118
|
+
let(:ar_response) { double("Response", content: "```ruby\nUser.count\n```") }
|
|
119
|
+
let(:ar_chat) do
|
|
120
|
+
double("Chat").tap { |c| allow(c).to receive(:ask).and_return(ar_response) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
before { allow(RubyLLM).to receive(:chat).and_return(ar_chat) }
|
|
124
|
+
|
|
125
|
+
it "returns the AR code content from the LLM response" do
|
|
126
|
+
result = described_class.build_ar_code("count users", [])
|
|
127
|
+
expect(result).to eq("```ruby\nUser.count\n```")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "calls RubyLLM.chat with the configured code provider and model" do
|
|
131
|
+
expect(RubyLLM).to receive(:chat).with(
|
|
132
|
+
hash_including(provider: Glancer.configuration.resolved_code_provider,
|
|
133
|
+
model: Glancer.configuration.resolved_code_model)
|
|
134
|
+
).and_return(ar_chat)
|
|
135
|
+
described_class.build_ar_code("count users", [])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "raises Glancer::Error when the LLM call fails" do
|
|
139
|
+
allow(RubyLLM).to receive(:chat).and_raise(StandardError, "timeout")
|
|
140
|
+
expect { described_class.build_ar_code("count users", []) }
|
|
141
|
+
.to raise_error(Glancer::Error, /AR code generation failed/)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# ── .fix_ar_code ──────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe ".fix_ar_code" do
|
|
148
|
+
let(:fixed_ar_response) { double("Response", content: "```ruby\nGlancer::Chat.count\n```") }
|
|
149
|
+
let(:fix_ar_chat) do
|
|
150
|
+
double("Chat").tap { |c| allow(c).to receive(:ask).and_return(fixed_ar_response) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
before { allow(RubyLLM).to receive(:chat).and_return(fix_ar_chat) }
|
|
154
|
+
|
|
155
|
+
it "returns the extracted AR code from the LLM correction" do
|
|
156
|
+
result = described_class.fix_ar_code("BadModel.all", "uninitialized constant")
|
|
157
|
+
expect(result).to eq("Glancer::Chat.count")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "calls RubyLLM.chat to get the correction" do
|
|
161
|
+
expect(RubyLLM).to receive(:chat).and_return(fix_ar_chat)
|
|
162
|
+
described_class.fix_ar_code("BadModel.all", "uninitialized constant")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "raises Glancer::Error when the LLM call fails" do
|
|
166
|
+
allow(RubyLLM).to receive(:chat).and_raise(StandardError, "LLM down")
|
|
167
|
+
expect { described_class.fix_ar_code("BadModel.all", "error") }
|
|
168
|
+
.to raise_error(Glancer::Error, /AR code correction failed/)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ── .fix_sql ─────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
describe ".fix_sql" do
|
|
175
|
+
let(:failed_sql) { "SELCT * FROM users" }
|
|
176
|
+
let(:error_message) { "syntax error near SELCT" }
|
|
177
|
+
let(:fixed_response) { double("Response", content: "```sql\nSELECT * FROM users\n```") }
|
|
178
|
+
let(:fix_chat) do
|
|
179
|
+
double("Chat").tap do |c|
|
|
180
|
+
allow(c).to receive(:ask).and_return(fixed_response)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
before do
|
|
185
|
+
allow(RubyLLM).to receive(:chat).and_return(fix_chat)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "returns extracted SQL from the LLM's corrected response" do
|
|
189
|
+
result = described_class.fix_sql(failed_sql, error_message)
|
|
190
|
+
expect(result).to eq("SELECT * FROM users")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "calls RubyLLM.chat to get the correction" do
|
|
194
|
+
expect(RubyLLM).to receive(:chat).and_return(fix_chat)
|
|
195
|
+
described_class.fix_sql(failed_sql, error_message)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "raises Glancer::Error when the LLM call fails" do
|
|
199
|
+
allow(RubyLLM).to receive(:chat).and_raise(StandardError, "LLM down")
|
|
200
|
+
expect { described_class.fix_sql(failed_sql, error_message) }
|
|
201
|
+
.to raise_error(Glancer::Error, /code correction workflow failed/)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::Cache do
|
|
6
|
+
let(:question) { "How many users are there?" }
|
|
7
|
+
let(:result) { { question: question, content: "42 users", code: "SELECT COUNT(*) FROM users", successful: true } }
|
|
8
|
+
|
|
9
|
+
before { described_class.clear }
|
|
10
|
+
|
|
11
|
+
describe ".fetch" do
|
|
12
|
+
context "when the cache is empty" do
|
|
13
|
+
it "returns nil for an unknown question" do
|
|
14
|
+
expect(described_class.fetch(question)).to be_nil
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
context "when the cache has a fresh entry" do
|
|
19
|
+
before { described_class.write(question, result) }
|
|
20
|
+
|
|
21
|
+
it "returns the cached entry" do
|
|
22
|
+
entry = described_class.fetch(question)
|
|
23
|
+
expect(entry).not_to be_nil
|
|
24
|
+
expect(entry[:content]).to eq("42 users")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "includes the cached_at timestamp" do
|
|
28
|
+
entry = described_class.fetch(question)
|
|
29
|
+
expect(entry[:cached_at]).to be_a(Time)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
context "when the cache entry is expired" do
|
|
34
|
+
before do
|
|
35
|
+
described_class.write(question, result)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "returns nil and removes the expired entry" do
|
|
39
|
+
Glancer.configuration.workflow_cache_ttl = 1 # 1 second TTL
|
|
40
|
+
# Simulate time passing beyond TTL
|
|
41
|
+
old_time = Time.now - 10
|
|
42
|
+
allow(Time).to receive(:current).and_return(old_time + 2)
|
|
43
|
+
|
|
44
|
+
# Write with 'old' cached_at by manipulating after the fact
|
|
45
|
+
described_class.clear
|
|
46
|
+
described_class.write(question, result)
|
|
47
|
+
# Now override Time.current to be far in the future
|
|
48
|
+
allow(Time).to receive(:current).and_return(Time.now + 3600)
|
|
49
|
+
|
|
50
|
+
expect(described_class.fetch(question)).to be_nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe ".write" do
|
|
56
|
+
it "stores the result merged with cached_at" do
|
|
57
|
+
described_class.write(question, result)
|
|
58
|
+
entry = described_class.fetch(question)
|
|
59
|
+
expect(entry).not_to be_nil
|
|
60
|
+
expect(entry[:cached_at]).to be_a(Time)
|
|
61
|
+
expect(entry[:code]).to eq("SELECT COUNT(*) FROM users")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "overwrites an existing entry for the same question" do
|
|
65
|
+
described_class.write(question, result)
|
|
66
|
+
new_result = result.merge(content: "Updated content")
|
|
67
|
+
described_class.write(question, new_result)
|
|
68
|
+
entry = described_class.fetch(question)
|
|
69
|
+
expect(entry[:content]).to eq("Updated content")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe ".clear" do
|
|
74
|
+
it "removes all cached entries" do
|
|
75
|
+
described_class.write(question, result)
|
|
76
|
+
described_class.write("another question", result)
|
|
77
|
+
described_class.clear
|
|
78
|
+
expect(described_class.fetch(question)).to be_nil
|
|
79
|
+
expect(described_class.fetch("another question")).to be_nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ── rescue paths ─────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe "rescue paths" do
|
|
86
|
+
it "fetch returns nil when an internal error occurs (e.g. expired? raises)" do
|
|
87
|
+
described_class.write(question, result)
|
|
88
|
+
allow(described_class).to receive(:expired?).and_raise(RuntimeError, "unexpected")
|
|
89
|
+
expect(described_class.fetch(question)).to be_nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "write handles errors gracefully without raising" do
|
|
93
|
+
store_mock = double("store")
|
|
94
|
+
allow(store_mock).to receive(:[]=).and_raise(StandardError, "write error")
|
|
95
|
+
allow(store_mock).to receive(:clear)
|
|
96
|
+
allow(store_mock).to receive(:[]).and_return(nil)
|
|
97
|
+
Glancer::Workflow::Cache.class_variable_set(:@@store, store_mock)
|
|
98
|
+
allow(Glancer::Utils::Logger).to receive(:error)
|
|
99
|
+
allow(Glancer::Utils::Logger).to receive(:debug)
|
|
100
|
+
expect { described_class.write(question, result) }.not_to raise_error
|
|
101
|
+
ensure
|
|
102
|
+
Glancer::Workflow::Cache.class_variable_set(:@@store, {})
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "clear handles unexpected errors gracefully" do
|
|
106
|
+
store_mock = double("store")
|
|
107
|
+
allow(store_mock).to receive(:clear).and_raise(StandardError, "cannot clear")
|
|
108
|
+
Glancer::Workflow::Cache.class_variable_set(:@@store, store_mock)
|
|
109
|
+
allow(Glancer::Utils::Logger).to receive(:error)
|
|
110
|
+
allow(Glancer::Utils::Logger).to receive(:debug)
|
|
111
|
+
expect { described_class.clear }.not_to raise_error
|
|
112
|
+
ensure
|
|
113
|
+
Glancer::Workflow::Cache.class_variable_set(:@@store, {})
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe ".expired?" do
|
|
118
|
+
context "with a fresh entry" do
|
|
119
|
+
it "returns false" do
|
|
120
|
+
entry = { cached_at: Time.now }
|
|
121
|
+
allow(Time).to receive(:current).and_return(Time.now)
|
|
122
|
+
Glancer.configuration.workflow_cache_ttl = 300
|
|
123
|
+
expect(described_class.expired?(entry)).to be(false)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
context "with an old entry beyond TTL" do
|
|
128
|
+
it "returns true" do
|
|
129
|
+
Glancer.configuration.workflow_cache_ttl = 1
|
|
130
|
+
entry = { cached_at: Time.now - 60 }
|
|
131
|
+
expect(described_class.expired?(entry)).to be(true)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
context "when entry has no cached_at" do
|
|
136
|
+
it "returns true (safe default)" do
|
|
137
|
+
entry = {}
|
|
138
|
+
expect(described_class.expired?(entry)).to be(true)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::Executor do
|
|
6
|
+
let(:valid_sql) { "SELECT 1 AS n" }
|
|
7
|
+
let(:update_sql) { "UPDATE users SET name = 'x'" }
|
|
8
|
+
|
|
9
|
+
# ── Security guard ────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe ".execute — security guard" do
|
|
12
|
+
it "raises Glancer::Error for UPDATE SQL" do
|
|
13
|
+
expect { described_class.execute(update_sql) }
|
|
14
|
+
.to raise_error(Glancer::Error, /Only SELECT queries are allowed/)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "raises Glancer::Error for DELETE SQL" do
|
|
18
|
+
expect { described_class.execute("DELETE FROM users") }
|
|
19
|
+
.to raise_error(Glancer::Error, /Only SELECT queries are allowed/)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "raises Glancer::Error for INSERT SQL" do
|
|
23
|
+
expect { described_class.execute("INSERT INTO users VALUES (1)") }
|
|
24
|
+
.to raise_error(Glancer::Error, /Only SELECT queries are allowed/)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "raises Glancer::Error for DROP SQL" do
|
|
28
|
+
expect { described_class.execute("DROP TABLE users") }
|
|
29
|
+
.to raise_error(Glancer::Error, /Only SELECT queries are allowed/)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "allows SELECT queries through" do
|
|
33
|
+
result = described_class.execute(valid_sql)
|
|
34
|
+
expect(result).to be_an(Array)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "allows WITH (CTE) queries through" do
|
|
38
|
+
cte_sql = "WITH t AS (SELECT 1 AS x) SELECT x FROM t"
|
|
39
|
+
expect { described_class.execute(cte_sql) }.not_to raise_error
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ── Successful execution ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe ".execute — successful execution" do
|
|
46
|
+
it "returns an array of result rows" do
|
|
47
|
+
result = described_class.execute(valid_sql)
|
|
48
|
+
expect(result).to be_an(Array)
|
|
49
|
+
expect(result.first).to include("n" => 1)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "creates a Glancer::Audit record after success" do
|
|
53
|
+
expect { described_class.execute(valid_sql, original_question: "What is 1?") }
|
|
54
|
+
.to change(Glancer::Audit, :count).by(1)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "stores the run_id in the audit record" do
|
|
58
|
+
described_class.execute(valid_sql)
|
|
59
|
+
audit = Glancer::Audit.last
|
|
60
|
+
expect(audit.run_id).to be_a(String)
|
|
61
|
+
expect(audit.run_id).not_to be_empty
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "appends the /*glancer,run_id:...*/ comment to the stored code" do
|
|
65
|
+
described_class.execute(valid_sql)
|
|
66
|
+
audit = Glancer::Audit.last
|
|
67
|
+
expect(audit.code).to include("/*glancer,run_id:")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "stores the original_question in the audit record" do
|
|
71
|
+
described_class.execute(valid_sql, original_question: "count rows")
|
|
72
|
+
expect(Glancer::Audit.last.question).to eq("count rows")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "stores the message_id when provided" do
|
|
76
|
+
chat = Glancer::Chat.create!(title: "C")
|
|
77
|
+
msg = Glancer::Message.create!(chat: chat, role: "assistant", content: "x")
|
|
78
|
+
described_class.execute(valid_sql, message_id: msg.id)
|
|
79
|
+
expect(Glancer::Audit.last.message_id).to eq(msg.id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "rolls back the transaction (read-only safety) — any DML inside rolled back" do
|
|
83
|
+
# The executor always rolls back even successful reads
|
|
84
|
+
# We verify by checking that the glancer_audits table's contents match our expectations
|
|
85
|
+
# but the executed SELECT 1 has no side-effects in any case; deeper test via custom SQL
|
|
86
|
+
result = described_class.execute("SELECT COUNT(*) AS cnt FROM glancer_chats")
|
|
87
|
+
expect(result.first["cnt"]).to eq(0)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── Retry on failure ──────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe ".execute — retry with Builder.fix_sql" do
|
|
94
|
+
let(:bad_sql) { "SELECT * FROM nonexistent_table_xyz" }
|
|
95
|
+
let(:fixed_sql) { "SELECT 1 AS n" }
|
|
96
|
+
|
|
97
|
+
before do
|
|
98
|
+
allow(Glancer::Workflow::Builder).to receive(:fix_sql).and_return(fixed_sql)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "calls Builder.fix_sql on first failure and retries" do
|
|
102
|
+
expect(Glancer::Workflow::Builder).to receive(:fix_sql).at_least(:once).and_return(fixed_sql)
|
|
103
|
+
described_class.execute(bad_sql)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "returns the result from the fixed SQL" do
|
|
107
|
+
result = described_class.execute(bad_sql)
|
|
108
|
+
expect(result).to be_an(Array)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "returns an error hash after 3 failed attempts" do
|
|
112
|
+
allow(Glancer::Workflow::Builder).to receive(:fix_sql).and_return(bad_sql)
|
|
113
|
+
result = described_class.execute(bad_sql, attempt: 1)
|
|
114
|
+
expect(result).to be_a(Hash)
|
|
115
|
+
expect(result[:error]).to be(true)
|
|
116
|
+
expect(result[:message]).to be_a(String)
|
|
117
|
+
expect(result[:last_code]).to eq(bad_sql)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ── apply_statement_timeout ───────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe ".apply_statement_timeout" do
|
|
124
|
+
let(:connection) { ActiveRecord::Base.connection }
|
|
125
|
+
|
|
126
|
+
it "does not raise for sqlite adapter (no timeout command)" do
|
|
127
|
+
Glancer.configuration.adapter = :sqlite
|
|
128
|
+
expect { described_class.apply_statement_timeout(connection) }.not_to raise_error
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "executes SET statement_timeout for postgres adapter" do
|
|
132
|
+
Glancer.configuration.adapter = :postgres
|
|
133
|
+
expect(connection).to receive(:execute).with(/SET statement_timeout/)
|
|
134
|
+
described_class.apply_statement_timeout(connection)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "executes SET max_execution_time for mysql adapter" do
|
|
138
|
+
Glancer.configuration.adapter = :mysql2
|
|
139
|
+
expect(connection).to receive(:execute).with(/SET max_execution_time/)
|
|
140
|
+
described_class.apply_statement_timeout(connection)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "does not raise when the DB rejects the timeout command" do
|
|
144
|
+
Glancer.configuration.adapter = :postgres
|
|
145
|
+
allow(connection).to receive(:execute).and_raise(StandardError, "unsupported")
|
|
146
|
+
expect { described_class.apply_statement_timeout(connection) }.not_to raise_error
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|