omen 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +108 -9
- data/app/models/omen/answer.rb +37 -0
- data/app/models/omen/asked.rb +10 -0
- data/app/models/omen/column.rb +60 -0
- data/app/models/omen/combination.rb +56 -0
- data/app/models/omen/conversation.rb +51 -0
- data/app/models/omen/executed.rb +39 -0
- data/app/models/omen/instructions.md +92 -0
- data/app/models/omen/instructions.rb +56 -0
- data/app/models/omen/query.rb +49 -0
- data/app/models/omen/question.rb +13 -0
- data/app/models/omen/reading.rb +18 -0
- data/app/models/omen/revealed.rb +30 -0
- data/app/models/omen/role.rb +29 -0
- data/app/models/omen/schema.rb +26 -0
- data/app/models/omen/spoken.rb +5 -0
- data/app/models/omen/stated.rb +21 -0
- data/db/migrate/20260821120000_create_omen_readings.rb +13 -0
- data/db/migrate/20260821120001_create_omen_questions.rb +9 -0
- data/db/migrate/20260821120002_create_omen_answers.rb +16 -0
- data/lib/generators/omen/install/install_generator.rb +31 -0
- data/lib/generators/omen/install/templates/omen.rb +35 -0
- data/lib/omen/config.rb +46 -0
- data/lib/omen/eastern.rb +22 -0
- data/lib/omen/engine.rb +10 -0
- data/lib/omen/inquirer.rb +91 -0
- data/lib/omen/requirements.rb +37 -0
- data/lib/omen/stubs.rb +41 -0
- data/lib/omen/version.rb +1 -1
- data/lib/omen.rb +17 -0
- data/lib/tasks/omen.rake +9 -0
- metadata +73 -2
- data/Rakefile +0 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# A thread of questions somebody asks Claude about the data this app holds.
|
|
2
|
+
class Omen::Reading < Omen.config.record
|
|
3
|
+
include Omen::Asked, Omen::Executed, Omen::Stated
|
|
4
|
+
|
|
5
|
+
has_many :questions, -> { order :id }, dependent: :destroy
|
|
6
|
+
|
|
7
|
+
# What the reading is opened with: a reading nobody asked anything is a reading of nothing.
|
|
8
|
+
attribute :question, :string
|
|
9
|
+
|
|
10
|
+
validates :question, presence: true, on: :create
|
|
11
|
+
|
|
12
|
+
after_create -> { ask question }
|
|
13
|
+
|
|
14
|
+
performs :run
|
|
15
|
+
|
|
16
|
+
# @return [String] the default representation (used in views).
|
|
17
|
+
def to_s = questions.first&.text.to_s.truncate 80
|
|
18
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Extends Omen::Answer with the plaintext behind the encrypted columns of its rows.
|
|
2
|
+
module Omen::Revealed extend ActiveSupport::Concern
|
|
3
|
+
# How Rails' encryption opens an envelope, in either case an expression may have left it in.
|
|
4
|
+
CIPHERTEXT = [ '{"p":', '{"P":' ]
|
|
5
|
+
|
|
6
|
+
# @return [Array<Hash>] the rows of the answer a page shows, read back where that is allowed.
|
|
7
|
+
def shown
|
|
8
|
+
@shown ||= result.first(Omen.config.maximum_rows).map { |row| combined revealed row }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def combined(row) = combinations.inject(row) { |left, one| one.applied left }
|
|
14
|
+
|
|
15
|
+
def combinations = @combinations ||= Omen::Combination.all(combine, result.first.to_h.keys)
|
|
16
|
+
|
|
17
|
+
def revealed(row) = row.to_h { |header, value| [ header, plain(header, value) ] }
|
|
18
|
+
|
|
19
|
+
def plain(header, value)
|
|
20
|
+
if (column = columns[provenance[header]])
|
|
21
|
+
column.read value
|
|
22
|
+
elsif value.to_s.start_with?(*CIPHERTEXT)
|
|
23
|
+
Omen::Column::HIDDEN
|
|
24
|
+
else
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def columns = @columns ||= Omen::Column.all.index_by(&:name)
|
|
30
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# The Postgres role a reading's statement runs as, which is refused the reading's own tables.
|
|
2
|
+
class Omen::Role
|
|
3
|
+
# Raised where the database has no such role, or has not granted it to the connecting user.
|
|
4
|
+
class Unavailable < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Ours to write, since it quotes no row: what an asker is told when it was the setup that failed.
|
|
7
|
+
MISCONFIGURED = 'This app is misconfigured, not the question -- ask an engineer to install ' \
|
|
8
|
+
'the read-only role a statement runs as.'
|
|
9
|
+
|
|
10
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] the one to switch.
|
|
11
|
+
def initialize(connection)
|
|
12
|
+
@connection = connection
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Given up before anything of ours runs; a statement Postgres refused rolls it back instead.
|
|
16
|
+
# @return [Object] whatever the block answered.
|
|
17
|
+
def around
|
|
18
|
+
enter
|
|
19
|
+
yield.tap { @connection.execute 'SET LOCAL ROLE NONE' }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def enter
|
|
25
|
+
@connection.execute "SET LOCAL ROLE #{@connection.quote_table_name Omen.config.narrow_role}"
|
|
26
|
+
rescue ActiveRecord::StatementInvalid
|
|
27
|
+
raise Unavailable, MISCONFIGURED
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# What Claude writes its SQL against: the app's own schema, less the tables a reading is kept in.
|
|
2
|
+
class Omen::Schema
|
|
3
|
+
# A plain index is a note about speed; a unique one is a fact about the rows, so it stays.
|
|
4
|
+
HINT = /^\s*t\.index (?!.*unique: true).*\n/
|
|
5
|
+
|
|
6
|
+
# @return [String] the schema, without this feature's own tables, their keys or their enum.
|
|
7
|
+
def text = without_orphan_enums hidden.inject(source) { |left, name| without_table left, name }
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def source = File.read(Omen.config.schema).gsub HINT, ''
|
|
12
|
+
|
|
13
|
+
def hidden = Omen.tables
|
|
14
|
+
|
|
15
|
+
def without_table(text, name)
|
|
16
|
+
text.gsub(/^ create_table "#{name}".*?\n end\n\n?/m, '')
|
|
17
|
+
.gsub(/^ add_foreign_key ("#{name}"|"\w+", "#{name}").*\n/, '')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def without_orphan_enums(text)
|
|
21
|
+
# Derived rather than named, so a type the cut tables shared with another survives
|
|
22
|
+
text.gsub(/^ create_enum "(\w+)".*\n/) do |line|
|
|
23
|
+
text.include?(%(enum_type: "#{Regexp.last_match 1}")) ? line : ''
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Extends a question and an answer with the prose said in it, in the blocks Claude speaks in.
|
|
2
|
+
module Omen::Spoken extend ActiveSupport::Concern
|
|
3
|
+
# @return [String] everything said in prose, which on an answer may be nothing.
|
|
4
|
+
def text = content.select { |block| block['type'] == 'text' }.pluck('text').join "\n"
|
|
5
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Extends Omen::Reading to have a status of its own, rather than one a host shares out.
|
|
2
|
+
module Omen::Stated extend ActiveSupport::Concern
|
|
3
|
+
# The states a reading passes through. The reading...
|
|
4
|
+
STATUSES = [
|
|
5
|
+
:unstarted, # ... has been asked something and has nothing back yet (default)
|
|
6
|
+
:started, # ... is being answered right now, by whichever run holds the claim
|
|
7
|
+
:completed, # ... has been answered, so another question may follow
|
|
8
|
+
:failed, # ... could not be answered at all, and is the asker's to try again
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
# How long a run holds its claim before another may take the reading over.
|
|
12
|
+
STALLED_AFTER = 10.minutes
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
enum :status, Hash[STATUSES.map { |status| [status, status] }]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def fresh? = updated_at.after? STALLED_AFTER.ago
|
|
21
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateOmenReadings < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_enum :omen_status, Omen::Stated::STATUSES
|
|
4
|
+
|
|
5
|
+
create_table :omen_readings do |t|
|
|
6
|
+
t.enum :status, enum_type: :omen_status, default: Omen::Stated::STATUSES.first, null: false
|
|
7
|
+
t.integer :input_usage, default: 0, null: false
|
|
8
|
+
t.integer :output_usage, default: 0, null: false
|
|
9
|
+
t.integer :questions_count, default: 0, null: false
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class CreateOmenQuestions < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :omen_questions do |t|
|
|
4
|
+
t.jsonb :content, default: [], null: false
|
|
5
|
+
t.references :reading, null: false, foreign_key: { to_table: :omen_readings }
|
|
6
|
+
t.timestamps
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateOmenAnswers < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :omen_answers do |t|
|
|
4
|
+
t.jsonb :content, default: [], null: false
|
|
5
|
+
t.jsonb :result, default: [], null: false
|
|
6
|
+
t.jsonb :provenance, default: {}, null: false
|
|
7
|
+
t.integer :input_usage, default: 0, null: false
|
|
8
|
+
t.integer :output_usage, default: 0, null: false
|
|
9
|
+
t.references :question, null: false, index: { unique: true },
|
|
10
|
+
foreign_key: { to_table: :omen_questions }
|
|
11
|
+
t.string :stop_reason
|
|
12
|
+
t.string :error
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require 'rails/generators/active_record'
|
|
2
|
+
|
|
3
|
+
module Omen
|
|
4
|
+
module Generators
|
|
5
|
+
# Everything installing this gem writes into a host: three migrations and one initializer.
|
|
6
|
+
# Not the roles or the database function, which are a rake task, because a copy of those
|
|
7
|
+
# would drift from what the gem goes on to expect and nothing would detect it.
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
# Both, since the migrations are shipped where they run from rather than as templates.
|
|
12
|
+
# @return [Array<String>] where a file being copied is looked for.
|
|
13
|
+
def self.source_paths
|
|
14
|
+
[ File.expand_path('templates', __dir__), Omen::Engine.root.join('db/migrate').to_s ]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Copied rather than read off the gem, so the host owns the files and their timestamps.
|
|
18
|
+
# @return [void]
|
|
19
|
+
def copy_migrations
|
|
20
|
+
Dir.children(Omen::Engine.root.join('db/migrate')).sort.each do |name|
|
|
21
|
+
migration_template name, "db/migrate/#{name.split('_', 2).last}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [void]
|
|
26
|
+
def copy_initializer
|
|
27
|
+
template 'omen.rb', 'config/initializers/omen.rb'
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Be sure to restart your server when you modify this file.
|
|
2
|
+
# Every setting Omen has is listed with the default it takes where the line is left out, so
|
|
3
|
+
# an app that accepts all of them can delete the block. What an app has to provide outside
|
|
4
|
+
# this file -- the read-only connection, the narrow role and the database function -- is in
|
|
5
|
+
# the gem's README, and none of it is optional.
|
|
6
|
+
|
|
7
|
+
Omen.configure do |config|
|
|
8
|
+
# The Active Record whose descendants are read for encrypted columns, and whose connection
|
|
9
|
+
# roles a statement is run through. A name, since nothing is autoloaded while this runs.
|
|
10
|
+
# config.record_class = 'ApplicationRecord'
|
|
11
|
+
|
|
12
|
+
# The Rails connection role a statement is read through. This app has to declare it with
|
|
13
|
+
# connects_to: Omen raises rather than falling back to a role that could write.
|
|
14
|
+
# config.reading_role = :reading
|
|
15
|
+
|
|
16
|
+
# The Postgres role the statement itself runs as, narrower again than the connection's own.
|
|
17
|
+
# Created by bin/rails db:omen:grant, which is also what revokes Omen's own tables from it.
|
|
18
|
+
# config.narrow_role = 'omen_inquirer'
|
|
19
|
+
|
|
20
|
+
# config.claude_model = 'claude-opus-5'
|
|
21
|
+
|
|
22
|
+
# How many rows of one answer a page shows. One more than this is read, so the page can say
|
|
23
|
+
# there are more without counting them.
|
|
24
|
+
# config.maximum_rows = 100
|
|
25
|
+
|
|
26
|
+
# Left unset, the Anthropic SDK resolves ANTHROPIC_API_KEY and its own wider chain.
|
|
27
|
+
# config.api_key = Rails.application.credentials.dig :anthropic, :api_key
|
|
28
|
+
|
|
29
|
+
# A file of prose about this app's own data: what it is, and where a table's rows really
|
|
30
|
+
# are. Everything the schema cannot say, and Claude is given it verbatim.
|
|
31
|
+
# config.notes = Rails.root.join 'config/omen_notes.md'
|
|
32
|
+
|
|
33
|
+
# Where Rails keeps the schema this app dumps, which is what Claude is shown.
|
|
34
|
+
# config.schema_path = 'db/schema.rb'
|
|
35
|
+
end
|
data/lib/omen/config.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Omen
|
|
2
|
+
# Every fact about the app this feature is installed in, so none of its own classes names one.
|
|
3
|
+
# All of them have a default, which is what makes the initializer a host writes optional.
|
|
4
|
+
class Config
|
|
5
|
+
# The Rails connection role a statement is read through. A host has to declare a read-only
|
|
6
|
+
# one: Omen raises rather than falling back to the writing role, which is the point.
|
|
7
|
+
attr_accessor :reading_role
|
|
8
|
+
|
|
9
|
+
# The Postgres role a statement runs as, narrower again than the connection's own.
|
|
10
|
+
attr_accessor :narrow_role
|
|
11
|
+
|
|
12
|
+
# The Claude model a question is asked of.
|
|
13
|
+
attr_accessor :claude_model
|
|
14
|
+
|
|
15
|
+
# How many rows of one answer a page will show.
|
|
16
|
+
attr_accessor :maximum_rows
|
|
17
|
+
|
|
18
|
+
# The key the Anthropic API is reached with. Left unset, the SDK resolves one of its own.
|
|
19
|
+
attr_accessor :api_key
|
|
20
|
+
|
|
21
|
+
# What a host states as a name or as a path, each of them resolved only when it is wanted.
|
|
22
|
+
attr_writer :record_class, :notes, :schema_path
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@reading_role = :reading
|
|
26
|
+
@narrow_role = 'omen_inquirer'
|
|
27
|
+
@claude_model = 'claude-opus-5'
|
|
28
|
+
@maximum_rows = 100
|
|
29
|
+
@schema_path = 'db/schema.rb'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A name rather than the class, since nothing is autoloaded while an initializer runs.
|
|
33
|
+
# @return [Class] the Active Record whose descendants and connection roles this feature uses.
|
|
34
|
+
def record = @record_class ? @record_class.constantize : default_record
|
|
35
|
+
|
|
36
|
+
# @return [String] the host's own notes about its data, which its schema cannot state.
|
|
37
|
+
def notes = @notes ? File.read(@notes) : ''
|
|
38
|
+
|
|
39
|
+
# @return [Pathname] the file Rails keeps in step with the database, which Claude is shown.
|
|
40
|
+
def schema = Rails.root.join @schema_path
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def default_record = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/omen/eastern.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Omen
|
|
2
|
+
# The database function a reading's SQL reads every timestamp through. Created by the rake
|
|
3
|
+
# task rather than by a migration, because Rails' :ruby schema format dumps no functions, so
|
|
4
|
+
# db:schema:load would drop one a migration had made -- and the format cannot become :sql,
|
|
5
|
+
# since db/schema.rb is what Claude is shown.
|
|
6
|
+
module Eastern
|
|
7
|
+
# The zone the company works in, as Postgres names one: Time.zone.name is not one it takes.
|
|
8
|
+
ZONE = 'America/New_York'
|
|
9
|
+
|
|
10
|
+
# DDL, which Active Record has no expression for, and not a query.
|
|
11
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
12
|
+
# @return [Array<String>] the statements to run, in order.
|
|
13
|
+
def self.statements(connection)
|
|
14
|
+
name = connection.quote_table_name Omen::Instructions::EASTERN
|
|
15
|
+
[
|
|
16
|
+
"CREATE OR REPLACE FUNCTION #{name}(ts timestamp) RETURNS timestamp AS " \
|
|
17
|
+
"$$ SELECT ts AT TIME ZONE 'UTC' AT TIME ZONE #{connection.quote ZONE} $$ " \
|
|
18
|
+
'LANGUAGE sql IMMUTABLE',
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/omen/engine.rb
CHANGED
|
@@ -2,5 +2,15 @@ module Omen
|
|
|
2
2
|
# Teaches Rails where this gem's models live, and prefixes their tables with `omen_`.
|
|
3
3
|
class Engine < ::Rails::Engine
|
|
4
4
|
isolate_namespace Omen
|
|
5
|
+
|
|
6
|
+
# Read off what the app declares rather than by connecting, and late enough that its own
|
|
7
|
+
# initializer has already said where its schema is. The declared format rather than the
|
|
8
|
+
# applied one, so nothing here depends on which after_initialize hook ran first.
|
|
9
|
+
config.after_initialize do
|
|
10
|
+
configured = ActiveRecord::Base.configurations.configs_for env_name: Rails.env,
|
|
11
|
+
name: 'primary'
|
|
12
|
+
Omen::Requirements.met adapter: configured&.adapter, schema: Omen.config.schema,
|
|
13
|
+
schema_format: Rails.application.config.active_record.schema_format
|
|
14
|
+
end
|
|
5
15
|
end
|
|
6
16
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Omen
|
|
2
|
+
# The Postgres role a reading's own SELECT runs as, blind to the tables a reading is kept in.
|
|
3
|
+
# It needs no database.yml entry of its own: NOLOGIN, it is a privilege container reached
|
|
4
|
+
# with SET LOCAL ROLE and never connected as.
|
|
5
|
+
module Inquirer
|
|
6
|
+
# What this role may never be: a superuser bypasses GRANT outright.
|
|
7
|
+
ATTRIBUTES = 'NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION'
|
|
8
|
+
|
|
9
|
+
# Whom to ask, since the role a host reads through is one this gem has no name for.
|
|
10
|
+
WHOEVER = 'SELECT current_user'
|
|
11
|
+
|
|
12
|
+
# Said on the way through installation step one, where a host has yet to declare the role.
|
|
13
|
+
UNGRANTED = 'No %{role} connection is configured, so nothing was granted to whatever ' \
|
|
14
|
+
'reads through it. Run this again once config/database.yml names one.'
|
|
15
|
+
|
|
16
|
+
# Creates the role and the function, on every database this environment prepares.
|
|
17
|
+
# @return [void]
|
|
18
|
+
def self.grant
|
|
19
|
+
environments.each do |environment|
|
|
20
|
+
config = ActiveRecord::Base.configurations.configs_for env_name: environment,
|
|
21
|
+
name: 'primary'
|
|
22
|
+
next unless config
|
|
23
|
+
ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection config do |connection|
|
|
24
|
+
grant_on connection
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<String>] the environments whose databases this run should cover.
|
|
30
|
+
def self.environments = Rails.env.development? ? %w[ development test ] : [Rails.env.to_s]
|
|
31
|
+
|
|
32
|
+
# Warns rather than raises: a managed database never grants CREATEROLE, and a deploy that
|
|
33
|
+
# cannot make the role must still finish, having said what has to be made by hand.
|
|
34
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
35
|
+
# @return [void]
|
|
36
|
+
def self.grant_on(connection)
|
|
37
|
+
read_by = reader
|
|
38
|
+
warn UNGRANTED % { role: Omen.config.reading_role } unless read_by
|
|
39
|
+
members = [ read_by, connection.select_value(WHOEVER) ].compact
|
|
40
|
+
statements(connection, members).each { |statement| connection.execute statement }
|
|
41
|
+
puts "Granted SELECT on #{connection.current_database} to #{Omen.config.narrow_role}"
|
|
42
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
43
|
+
warn "Could not make #{Omen.config.narrow_role}, so every reading will say this app is " \
|
|
44
|
+
'misconfigured. Ask for that role, NOLOGIN, granted SELECT on every table but ' \
|
|
45
|
+
"#{Omen.tables.to_sentence}: #{error.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Discovered rather than named: SET LOCAL ROLE needs the connecting role to be a member of
|
|
49
|
+
# this one, and the role a host's reading connection logs in as is the host's own business.
|
|
50
|
+
# @return [String, nil] the Postgres user a reading is read through, where there is one.
|
|
51
|
+
def self.reader
|
|
52
|
+
Omen.config.record.connected_to role: Omen.config.reading_role do
|
|
53
|
+
Omen.config.record.with_connection { |connection| connection.select_value WHOEVER }
|
|
54
|
+
end
|
|
55
|
+
rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# DDL, which Active Record has no expression for, and not a query.
|
|
60
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
61
|
+
# @param members [Array<String>] the roles that may SET LOCAL ROLE to this one. The owner
|
|
62
|
+
# running the task is one of them, and matters in tests, where Rails swaps the reading
|
|
63
|
+
# pool for the writing one and the test connection is the owner.
|
|
64
|
+
# @return [Array<String>] the statements to run, in order.
|
|
65
|
+
def self.statements(connection, members)
|
|
66
|
+
role = connection.quote_table_name Omen.config.narrow_role
|
|
67
|
+
[
|
|
68
|
+
'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
|
|
69
|
+
"#{connection.quote Omen.config.narrow_role}) THEN CREATE ROLE #{role} NOLOGIN; " \
|
|
70
|
+
'END IF; END $$',
|
|
71
|
+
"ALTER ROLE #{role} WITH NOLOGIN #{ATTRIBUTES}",
|
|
72
|
+
"GRANT USAGE ON SCHEMA public TO #{role}",
|
|
73
|
+
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{role}",
|
|
74
|
+
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO #{role}",
|
|
75
|
+
*members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
|
|
76
|
+
*revoked(connection, role),
|
|
77
|
+
*Eastern.statements(connection),
|
|
78
|
+
]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Intersected, so a bare db:create with no table yet to revoke on is not a failure.
|
|
82
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
83
|
+
# @param role [String] the role to hide this feature's own tables from.
|
|
84
|
+
# @return [Array<String>] one REVOKE per table there is.
|
|
85
|
+
def self.revoked(connection, role)
|
|
86
|
+
(connection.tables & Omen.tables).map do |table|
|
|
87
|
+
"REVOKE SELECT ON #{connection.quote_table_name table} FROM #{role}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Omen
|
|
2
|
+
# What an app has to be for this gem to keep its promises, checked as that app boots. Both
|
|
3
|
+
# of these rule an app out rather than inconveniencing it, so neither is worth deferring.
|
|
4
|
+
module Requirements
|
|
5
|
+
# Raised where an app cannot be one this gem works in, however else it is configured.
|
|
6
|
+
class Unmet < StandardError; end
|
|
7
|
+
|
|
8
|
+
# The one adapter that reports the table a result column came from, and scopes a role to
|
|
9
|
+
# a transaction. Everything else Omen promises rests on one of those two.
|
|
10
|
+
ADAPTER = 'postgresql'
|
|
11
|
+
|
|
12
|
+
# The one schema format that can be read as a prompt.
|
|
13
|
+
FORMAT = :ruby
|
|
14
|
+
|
|
15
|
+
# Said where the app reaches its database through anything else.
|
|
16
|
+
WRONG_ADAPTER = 'Omen needs a postgresql database, and this app names the adapter'
|
|
17
|
+
|
|
18
|
+
# Said where the app dumps its schema as SQL, which cannot be shown to Claude.
|
|
19
|
+
WRONG_FORMAT = 'Omen shows Claude db/schema.rb, so config.active_record.schema_format ' \
|
|
20
|
+
'has to be :ruby, and this app sets'
|
|
21
|
+
|
|
22
|
+
# Said where the file is not there at all: a run would fail one question at a time, and
|
|
23
|
+
# a structure.sql copied to that path would leave the reading tables in the prompt.
|
|
24
|
+
NO_SCHEMA = 'Omen shows Claude the schema Rails dumps, and there is no file at'
|
|
25
|
+
|
|
26
|
+
# @param adapter [String, nil] what this app reaches its own database through.
|
|
27
|
+
# @param schema_format [Symbol, nil] the format it declares, nil where it declares none.
|
|
28
|
+
# @param schema [Pathname] where the dump is kept.
|
|
29
|
+
# @return [void]
|
|
30
|
+
def self.met(adapter:, schema_format:, schema:)
|
|
31
|
+
dumps = schema_format || FORMAT
|
|
32
|
+
raise Unmet, "#{WRONG_ADAPTER} #{adapter.inspect}" unless adapter == ADAPTER
|
|
33
|
+
raise Unmet, "#{WRONG_FORMAT} #{dumps.inspect}" unless dumps == FORMAT
|
|
34
|
+
raise Unmet, "#{NO_SCHEMA} #{schema}" unless File.exist? schema
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/omen/stubs.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Answers the Anthropic API with canned turns, so no test of a host reaches the network.
|
|
2
|
+
module Omen::Stubs extend ActiveSupport::Concern
|
|
3
|
+
# Where a message is asked for.
|
|
4
|
+
MESSAGES_URL = 'https://api.anthropic.com/v1/messages'
|
|
5
|
+
|
|
6
|
+
# Answers each request with the next turn, so a question and its refinement are scripted apart.
|
|
7
|
+
# @param turns [Array<Hash>] what Claude says, in order.
|
|
8
|
+
def stub_claude(*turns)
|
|
9
|
+
stub_request(:post, MESSAGES_URL).
|
|
10
|
+
to_return(*turns.map { |turn| { body: turn.to_json, headers: json_headers } })
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Carries a stray `caller`, so a test notices if a reply is ever replayed whole.
|
|
14
|
+
# @param sql [String] the statement Claude answers with, or '' to ask something instead.
|
|
15
|
+
# @param note [String] what Claude says about it.
|
|
16
|
+
# @param combine [Array<Hash>] the columns it asks to be drawn as their parts joined.
|
|
17
|
+
# @return [Hash] a turn where Claude answers in the shape the output schema demands.
|
|
18
|
+
def claude_answers(sql: '', note: '', combine: [])
|
|
19
|
+
answered = { sql: sql, note: note, combine: combine }
|
|
20
|
+
claude_turn 'end_turn', [ { type: 'text', text: answered.to_json,
|
|
21
|
+
caller: { type: 'assistant' }, } ]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A reply cut short at max_tokens is not JSON, whatever the output schema demanded.
|
|
25
|
+
# @param text [String] what Claude got as far as saying.
|
|
26
|
+
# @return [Hash] a turn the API stopped mid-sentence.
|
|
27
|
+
def claude_says(text)
|
|
28
|
+
claude_turn 'max_tokens', [ { type: 'text', text: text } ]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def claude_turn(stop_reason, content)
|
|
34
|
+
{ id: 'msg_01', type: 'message', role: 'assistant', model: 'claude-opus-5',
|
|
35
|
+
content: content, stop_reason: stop_reason,
|
|
36
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def json_headers = { 'Content-Type' => 'application/json' }
|
|
41
|
+
end
|
data/lib/omen/version.rb
CHANGED
data/lib/omen.rb
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
|
+
require 'active_job/performs'
|
|
2
|
+
require 'anthropic'
|
|
3
|
+
|
|
4
|
+
require 'omen/config'
|
|
5
|
+
require 'omen/eastern'
|
|
6
|
+
require 'omen/inquirer'
|
|
7
|
+
require 'omen/requirements'
|
|
1
8
|
require 'omen/version'
|
|
2
9
|
require 'omen/engine'
|
|
3
10
|
|
|
4
11
|
# Staff ask Claude a question about the data an app holds; Claude writes the SQL, Rails runs it.
|
|
5
12
|
module Omen
|
|
13
|
+
# Hidden twice over: out of the prompt, and out of what the statement's own role may read.
|
|
14
|
+
# @return [Array<String>] the tables this feature keeps its log of questions and answers in.
|
|
15
|
+
def self.tables = [ Omen::Reading, Omen::Question, Omen::Answer ].map(&:table_name)
|
|
16
|
+
|
|
17
|
+
# @return [Omen::Config] everything this feature has to be told about the app around it.
|
|
18
|
+
def self.config = @config ||= Omen::Config.new
|
|
19
|
+
|
|
20
|
+
# Yields the configuration, so a host states its own facts in one initializer.
|
|
21
|
+
# @return [void]
|
|
22
|
+
def self.configure = yield config
|
|
6
23
|
end
|
data/lib/tasks/omen.rake
ADDED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omen
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
@@ -9,6 +9,48 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: active_job-performs
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: anthropic
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: pg
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
12
54
|
- !ruby/object:Gem::Dependency
|
|
13
55
|
name: rails
|
|
14
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -30,11 +72,39 @@ executables: []
|
|
|
30
72
|
extensions: []
|
|
31
73
|
extra_rdoc_files: []
|
|
32
74
|
files:
|
|
75
|
+
- CHANGELOG.md
|
|
76
|
+
- LICENSE.txt
|
|
33
77
|
- README.md
|
|
34
|
-
-
|
|
78
|
+
- app/models/omen/answer.rb
|
|
79
|
+
- app/models/omen/asked.rb
|
|
80
|
+
- app/models/omen/column.rb
|
|
81
|
+
- app/models/omen/combination.rb
|
|
82
|
+
- app/models/omen/conversation.rb
|
|
83
|
+
- app/models/omen/executed.rb
|
|
84
|
+
- app/models/omen/instructions.md
|
|
85
|
+
- app/models/omen/instructions.rb
|
|
86
|
+
- app/models/omen/query.rb
|
|
87
|
+
- app/models/omen/question.rb
|
|
88
|
+
- app/models/omen/reading.rb
|
|
89
|
+
- app/models/omen/revealed.rb
|
|
90
|
+
- app/models/omen/role.rb
|
|
91
|
+
- app/models/omen/schema.rb
|
|
92
|
+
- app/models/omen/spoken.rb
|
|
93
|
+
- app/models/omen/stated.rb
|
|
94
|
+
- db/migrate/20260821120000_create_omen_readings.rb
|
|
95
|
+
- db/migrate/20260821120001_create_omen_questions.rb
|
|
96
|
+
- db/migrate/20260821120002_create_omen_answers.rb
|
|
97
|
+
- lib/generators/omen/install/install_generator.rb
|
|
98
|
+
- lib/generators/omen/install/templates/omen.rb
|
|
35
99
|
- lib/omen.rb
|
|
100
|
+
- lib/omen/config.rb
|
|
101
|
+
- lib/omen/eastern.rb
|
|
36
102
|
- lib/omen/engine.rb
|
|
103
|
+
- lib/omen/inquirer.rb
|
|
104
|
+
- lib/omen/requirements.rb
|
|
105
|
+
- lib/omen/stubs.rb
|
|
37
106
|
- lib/omen/version.rb
|
|
107
|
+
- lib/tasks/omen.rake
|
|
38
108
|
homepage: https://github.com/claudiob/omen
|
|
39
109
|
licenses:
|
|
40
110
|
- MIT
|
|
@@ -42,6 +112,7 @@ metadata:
|
|
|
42
112
|
homepage_uri: https://github.com/claudiob/omen
|
|
43
113
|
source_code_uri: https://github.com/claudiob/omen/
|
|
44
114
|
changelog_uri: https://github.com/claudiob/omen/blob/main/CHANGELOG.md
|
|
115
|
+
documentation_uri: https://rubydoc.info/gems/omen
|
|
45
116
|
rdoc_options: []
|
|
46
117
|
require_paths:
|
|
47
118
|
- lib
|
data/Rakefile
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
require 'bundler/gem_tasks'
|