durable_agent 0.1.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/DESIGN.md +614 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +23 -0
  6. data/Rakefile +46 -0
  7. data/app/controllers/durable_agent/application_controller.rb +15 -0
  8. data/app/controllers/durable_agent/events_controller.rb +28 -0
  9. data/app/controllers/durable_agent/tools_controller.rb +33 -0
  10. data/app/models/concerns/durable_agent/harness_record.rb +11 -0
  11. data/app/models/durable_agent/application_record.rb +5 -0
  12. data/app/models/durable_agent/connection.rb +66 -0
  13. data/app/models/durable_agent/conversation.rb +107 -0
  14. data/app/models/durable_agent/document/revision.rb +16 -0
  15. data/app/models/durable_agent/document.rb +27 -0
  16. data/app/models/durable_agent/entry.rb +56 -0
  17. data/app/models/durable_agent/report.rb +14 -0
  18. data/app/models/durable_agent/submission.rb +57 -0
  19. data/app/models/durable_agent/task.rb +17 -0
  20. data/config/routes.rb +5 -0
  21. data/docs/guide.md +421 -0
  22. data/lib/durable_agent/account.rb +29 -0
  23. data/lib/durable_agent/client.rb +121 -0
  24. data/lib/durable_agent/configuration.rb +81 -0
  25. data/lib/durable_agent/current.rb +6 -0
  26. data/lib/durable_agent/engine.rb +7 -0
  27. data/lib/durable_agent/indexed_string.rb +14 -0
  28. data/lib/durable_agent/message_text.rb +23 -0
  29. data/lib/durable_agent/sidecar.rb +97 -0
  30. data/lib/durable_agent/test_helper.rb +110 -0
  31. data/lib/durable_agent/tool.rb +91 -0
  32. data/lib/durable_agent/version.rb +3 -0
  33. data/lib/durable_agent.rb +67 -0
  34. data/lib/generators/durable_agent/install/install_generator.rb +75 -0
  35. data/lib/generators/durable_agent/install/templates/create_durable_agent_tables.rb.tt +120 -0
  36. data/lib/generators/durable_agent/install/templates/durable_agent.rb.tt +36 -0
  37. data/lib/tasks/durable_agent_tasks.rake +6 -0
  38. data/sidecar/dist/sidecar.js +76417 -0
  39. data/sidecar/package.json +32 -0
  40. data/sidecar/sql/schema.postgres.sql +120 -0
  41. data/sidecar/sql/schema.sqlite.sql +120 -0
  42. metadata +115 -0
@@ -0,0 +1,14 @@
1
+ require "json"
2
+
3
+ module DurableAgent
4
+ # pi-durable indexes some strings (task and document kinds, document keys, request ids) by their JSON encoding,
5
+ # written in the sidecar by JavaScript's JSON.stringify. A lookup must produce the same bytes. Rails' to_json
6
+ # escapes &, < and > (and U+2028/U+2029) as \uXXXX, which JSON.stringify never does; JSON.generate escapes only
7
+ # what JSON requires, as JSON.stringify does.
8
+ module IndexedString
9
+ extend self
10
+
11
+ def encode(value) = JSON.generate(value.to_s)
12
+ def decode(text) = JSON.parse(text)
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ module DurableAgent
2
+ # Turns pi messages (an entry's `model` column, or the streaming partial in pi.live) into plain text.
3
+ # Content is either a string or an array of blocks: text, thinking, toolCall, image.
4
+ module MessageText
5
+ extend self
6
+
7
+ def text(messages)
8
+ blocks(messages).filter_map { |block| block.is_a?(String) ? block : (block["text"] if block["type"] == "text") }.join
9
+ end
10
+
11
+ def tool_calls(messages)
12
+ blocks(messages).select { |block| block.is_a?(Hash) && block["type"] == "toolCall" }
13
+ end
14
+
15
+ def thinking?(messages)
16
+ blocks(messages).any? { |block| block.is_a?(Hash) && block["type"] == "thinking" }
17
+ end
18
+
19
+ def blocks(messages)
20
+ Array.wrap(messages).flat_map { |message| Array.wrap(message["content"]) }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,97 @@
1
+ require "erb"
2
+
3
+ module DurableAgent
4
+ # Starts the bundled sidecar with every setting derived from the Rails app, so the two halves agree.
5
+ module Sidecar
6
+ extend self
7
+
8
+ PASS_THROUGH = %w[ OPENAI_API_KEY ANTHROPIC_API_KEY ].freeze
9
+
10
+ def env
11
+ config = DurableAgent.config
12
+
13
+ {
14
+ "DATABASE_URL" => database_url,
15
+ "DURABLE_AGENT_TOKEN" => config.token,
16
+ "DURABLE_AGENT_HOST" => config.host.to_s,
17
+ "DURABLE_AGENT_PORT" => config.port.to_s,
18
+ "DURABLE_AGENT_RAILS_URL" => config.rails_url.to_s,
19
+ "DURABLE_AGENT_MODEL" => config.model.to_s,
20
+ "DURABLE_AGENT_WORKSPACE_ROOT" => config.workspace_root.to_s,
21
+ "DURABLE_AGENT_NAME" => config.agent_name.to_s
22
+ }.merge(ENV.slice(*PASS_THROUGH))
23
+ end
24
+
25
+ # The one place to swap in a compiled binary later. Falls back to the TypeScript source when no bundle was
26
+ # built, which Node runs directly with type stripping.
27
+ def command
28
+ bundle = root.join("dist/sidecar.js")
29
+ [ "node", (bundle.exist? ? bundle : root.join("src/main.ts")).to_s ]
30
+ end
31
+
32
+ def exec
33
+ FileUtils.mkdir_p(DurableAgent.config.workspace_root)
34
+ Kernel.exec(env, *command)
35
+ end
36
+
37
+ def root
38
+ DurableAgent::Engine.root.join("sidecar")
39
+ end
40
+
41
+ # The app's primary database, which the sidecar opens too: pi-durable's tables live there.
42
+ def database_url(db_config = ActiveRecord::Base.connection_db_config)
43
+ return node_sslmode_in(db_config.url) if db_config.respond_to?(:url) && db_config.url.present?
44
+
45
+ case db_config.adapter
46
+ when "sqlite3" then sqlite_url(db_config.configuration_hash)
47
+ when "postgresql" then postgres_url(db_config.configuration_hash)
48
+ else raise ArgumentError, "durable_agent needs a postgresql or sqlite3 database, not #{db_config.adapter}"
49
+ end
50
+ end
51
+
52
+ # libpq's sslmode=require encrypts without verifying the server's certificate; node-postgres reads `require`
53
+ # as verify-full and would refuse the self-signed certificates many hosted databases use. `no-verify` is
54
+ # node-postgres' name for libpq's `require`. verify-ca and verify-full pass through and verify.
55
+ NODE_SSLMODES = { "require" => "no-verify" }.freeze
56
+
57
+ private
58
+ def sqlite_url(config)
59
+ "sqlite://#{File.expand_path(config[:database].to_s, Rails.root)}"
60
+ end
61
+
62
+ def postgres_url(config)
63
+ userinfo = [ config[:username], config[:password] ].compact.map { |part| ERB::Util.url_encode(part.to_s) }.join(":")
64
+ host = config[:host].presence || "localhost"
65
+ query = {}
66
+
67
+ # A socket directory cannot sit in the authority part of the URL; libpq and node-postgres take it as ?host=.
68
+ if host.start_with?("/")
69
+ query[:host] = host
70
+ host = ""
71
+ end
72
+ query[:sslmode] = NODE_SSLMODES.fetch(config[:sslmode].to_s, config[:sslmode].to_s) if config[:sslmode].present?
73
+ %i[ sslrootcert sslcert sslkey ].each do |key|
74
+ query[key] = File.expand_path(config[key].to_s, Rails.root) if config[key].present?
75
+ end
76
+ query[:options] = "-c search_path=#{search_path(config[:schema_search_path])}" if config[:schema_search_path].present?
77
+
78
+ authority = [ userinfo.presence, host ].compact.join("@")
79
+ authority += ":#{config[:port]}" if config[:port].present?
80
+ url = "postgres://#{authority}/#{ERB::Util.url_encode(config[:database].to_s)}"
81
+ query.any? ? "#{url}?#{query.map { |key, value| "#{key}=#{ERB::Util.url_encode(value)}" }.join("&")}" : url
82
+ end
83
+
84
+ # A startup option is split on spaces, so "public, shared" must lose its spaces (and any left be escaped).
85
+ def search_path(value)
86
+ value.to_s.split(",").map(&:strip).join(",").gsub(" ", "\\ ")
87
+ end
88
+
89
+ def node_sslmode_in(url)
90
+ if url.start_with?("postgres:", "postgresql:")
91
+ url.sub(/([?&]sslmode=)([^&#]*)/) { "#{$1}#{NODE_SSLMODES.fetch($2, $2)}" }
92
+ else
93
+ url
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,110 @@
1
+ module DurableAgent
2
+ # Test support for apps built on durable_agent. In test/test_helper.rb:
3
+ #
4
+ # require "durable_agent/test_helper"
5
+ #
6
+ # class ActiveSupport::TestCase
7
+ # include DurableAgent::TestHelper
8
+ # end
9
+ #
10
+ # `stub_sidecar` replaces DurableAgent.client calls with canned responses or callables and records each call.
11
+ # It also seals the client: every other call raises DurableAgent::Unavailable, so a test never reaches a sidecar
12
+ # that happens to be running. `fake_sidecar` goes further for the three commands that write: like the real sidecar,
13
+ # it commits the conversation or submission row before it answers, so `DurableAgent.start` and `conversation.say`
14
+ # find them.
15
+ #
16
+ # fake_sidecar
17
+ # chat = Chat.create! # calls DurableAgent.start
18
+ # chat.conversation.say("Hi", request_id: 1)
19
+ # assert_equal 1, sidecar_calls(:submit).size
20
+ module TestHelper
21
+ DISCONNECTED = { "openai" => nil, "anthropic" => nil }.freeze
22
+ CONNECTED = { "openai" => { "type" => "oauth", "source" => "OAuth" }, "anthropic" => nil }.freeze
23
+
24
+ # Ids the fake hands out: above the ids pi-durable starts from, and above the gem's own test fixtures.
25
+ FAKE_IDS_FROM = 950_000
26
+
27
+ # The client's calls to the sidecar; `url` and `token` are configuration.
28
+ SIDECAR_CALLS = DurableAgent::Client.public_instance_methods(false) - %i[ url token ]
29
+
30
+ # stub_sidecar(auth: CONNECTED, submit: ->(id, content, **) { { "id" => 1 } }, abort: DurableAgent::Unavailable)
31
+ def stub_sidecar(**responses)
32
+ client = DurableAgent.client
33
+ responses.each_key { |name| raise ArgumentError, "DurableAgent::Client has no method #{name}" unless client.respond_to?(name) }
34
+
35
+ @sidecar_stubbed ||= []
36
+ sealed = (SIDECAR_CALLS - @sidecar_stubbed - responses.keys).index_with do |name|
37
+ ->(*, **) { raise DurableAgent::Unavailable.new(nil, nil, "DurableAgent.client.#{name} is not stubbed; stub_sidecar(#{name}: ...)") }
38
+ end
39
+
40
+ calls = sidecar_calls
41
+ responses.merge(sealed).each do |name, response|
42
+ @sidecar_stubbed << name
43
+ client.define_singleton_method(name) do |*args, **kwargs|
44
+ calls << [ name, args, kwargs ]
45
+ if response.is_a?(Exception) || (response.is_a?(Class) && response < Exception)
46
+ raise response
47
+ else
48
+ response.respond_to?(:call) ? response.call(*args, **kwargs) : response
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ # Every stubbed call as [name, args, kwargs], or only those of one name.
55
+ def sidecar_calls(name = nil)
56
+ calls = (@sidecar_calls ||= [])
57
+ name ? calls.select { |call| call.first == name } : calls
58
+ end
59
+
60
+ # create_conversation, submit and abort answered as the sidecar answers them, after committing the rows it
61
+ # commits. Submissions are idempotent on request_id, and an unknown conversation is a 404, as on the sidecar.
62
+ # Further responses stub other calls: fake_sidecar(auth: CONNECTED).
63
+ def fake_sidecar(**responses)
64
+ stub_sidecar(create_conversation: method(:fake_create_conversation), submit: method(:fake_submit),
65
+ abort: method(:fake_abort), **responses)
66
+ end
67
+
68
+ def teardown
69
+ super
70
+ Array(@sidecar_stubbed).uniq.each { |name| DurableAgent.client.singleton_class.send(:remove_method, name) }
71
+ @sidecar_stubbed = @sidecar_calls = @fake_sidecar_id = nil
72
+ end
73
+
74
+ private
75
+ def fake_create_conversation(model: nil, instructions: nil, account: nil)
76
+ id = next_fake_sidecar_id
77
+ DurableAgent::Conversation.insert({ id: id, record: { id: id } })
78
+ { "id" => id }
79
+ end
80
+
81
+ def fake_submit(conversation_id, content, request_id:, when_busy: nil)
82
+ fake_conversation!(conversation_id)
83
+
84
+ submission = DurableAgent::Submission.where(conversation_id: conversation_id).for_request(request_id).first
85
+ submission ||= begin
86
+ id = next_fake_sidecar_id
87
+ DurableAgent::Submission.insert({ id: id, conversation_id: conversation_id, request_id: DurableAgent::IndexedString.encode(request_id),
88
+ status: "queued", record: { id: id, conversationId: conversation_id, requestId: request_id.to_s, type: "input", status: "queued" } })
89
+ DurableAgent::Submission.find(id)
90
+ end
91
+
92
+ { "id" => submission.id, "status" => submission.status }
93
+ end
94
+
95
+ def fake_abort(conversation_id)
96
+ fake_conversation!(conversation_id)
97
+ { "ok" => true }
98
+ end
99
+
100
+ def fake_conversation!(id)
101
+ unless DurableAgent::Conversation.exists?(id)
102
+ raise DurableAgent::Error.new(404, { "error" => "conversation #{id} not found" })
103
+ end
104
+ end
105
+
106
+ def next_fake_sidecar_id
107
+ @fake_sidecar_id = (@fake_sidecar_id || FAKE_IDS_FROM) + 1
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,91 @@
1
+ module DurableAgent
2
+ # A tool the agent can call, written in Ruby. The sidecar fetches every tool's definition from
3
+ # GET /durable_agent/tools and runs one with POST /durable_agent/tools/:name.
4
+ #
5
+ # class LookupInvoice < DurableAgent::Tool
6
+ # description "Fetch one invoice by number"
7
+ # param :number, :string, "Invoice number like INV-1042"
8
+ # param :include_lines, :boolean, "Include line items", required: false
9
+ # replay :safe
10
+ #
11
+ # def call(number:, include_lines: false)
12
+ # Invoice.find_by!(number:).as_json
13
+ # end
14
+ # end
15
+ class Tool
16
+ # Raise this with a message for the model: "No invoice INV-9; numbers look like INV-1042". Other errors reach
17
+ # the model as their class name only.
18
+ class Error < StandardError; end
19
+
20
+ TYPES = %i[ string integer number boolean array object ].freeze
21
+ REPLAY_MODES = %i[ safe unsafe ].freeze
22
+
23
+ class_attribute :params, instance_accessor: false, default: {}
24
+
25
+ class << self
26
+ def description(text = nil)
27
+ text ? @description = text : @description.to_s
28
+ end
29
+
30
+ # Extra JSON Schema keywords (enum:, items:, properties:) pass straight through.
31
+ def param(name, type, description = nil, required: true, **schema)
32
+ raise ArgumentError, "unknown param type #{type.inspect}" unless type.in?(TYPES)
33
+ self.params = params.merge(name.to_sym => { type: type, description: description, required: required, schema: schema })
34
+ end
35
+
36
+ # Whether pi may run the tool again after a crash mid-call. Unsafe tools are reported instead of repeated.
37
+ def replay(mode = nil)
38
+ if mode
39
+ raise ArgumentError, "replay is :safe or :unsafe" unless mode.in?(REPLAY_MODES)
40
+ @replay = mode
41
+ else
42
+ @replay || (superclass.respond_to?(:replay) ? superclass.replay : :unsafe)
43
+ end
44
+ end
45
+
46
+ def tool_name(name = nil)
47
+ name ? @tool_name = name.to_s : @tool_name || self.name.demodulize.underscore
48
+ end
49
+
50
+ def to_definition
51
+ {
52
+ name: tool_name,
53
+ description: description,
54
+ parameters: {
55
+ type: "object",
56
+ properties: params.transform_values { |param| { type: param[:type].to_s, description: param[:description] }.compact.merge(param[:schema]) },
57
+ required: params.select { |_, param| param[:required] }.keys.map(&:to_s),
58
+ additionalProperties: false
59
+ },
60
+ replay: replay.to_s
61
+ }
62
+ end
63
+
64
+ # Runs the tool with the arguments object the model produced (JSON, string keys).
65
+ def run(arguments)
66
+ new.call(**coerce(arguments))
67
+ end
68
+
69
+ def coerce(arguments)
70
+ arguments ||= {}
71
+ raise ArgumentError, "arguments must be an object" unless arguments.is_a?(Hash)
72
+ arguments = arguments.transform_keys(&:to_s)
73
+
74
+ unknown = arguments.keys - params.keys.map(&:to_s)
75
+ raise ArgumentError, "unknown #{"argument".pluralize(unknown.size)}: #{unknown.join(", ")}" if unknown.any?
76
+
77
+ # Models send null for optional arguments they skip; let the method's default apply instead.
78
+ given = arguments.compact.transform_keys(&:to_sym)
79
+
80
+ missing = params.select { |name, param| param[:required] && !given.key?(name) }.keys
81
+ raise ArgumentError, "missing required #{"argument".pluralize(missing.size)}: #{missing.join(", ")}" if missing.any?
82
+
83
+ given
84
+ end
85
+ end
86
+
87
+ def call(**)
88
+ raise NotImplementedError, "#{self.class.name} must implement #call"
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,3 @@
1
+ module DurableAgent
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,67 @@
1
+ require "durable_agent/version"
2
+ require "durable_agent/engine"
3
+ require "durable_agent/configuration"
4
+ require "durable_agent/account"
5
+ require "durable_agent/client"
6
+ require "durable_agent/current"
7
+ require "durable_agent/message_text"
8
+ require "durable_agent/indexed_string"
9
+ require "durable_agent/tool"
10
+ require "durable_agent/sidecar"
11
+
12
+ module DurableAgent
13
+ class << self
14
+ def configure
15
+ yield config
16
+ end
17
+
18
+ def config
19
+ @config ||= Configuration.new
20
+ end
21
+
22
+ def client
23
+ @client ||= Client.new
24
+ end
25
+
26
+ # Creates a conversation on the sidecar and returns its row, which the sidecar commits before it answers.
27
+ # Without a model the sidecar uses its default, which `durable_agent:sidecar` sets from config.model.
28
+ #
29
+ # With an account the conversation runs on that account's provider credentials (see Connection), without one
30
+ # on the app's. The sidecar commits in its own transaction, so this cannot join the caller's: when the app's own
31
+ # record fails to save afterwards, the conversation stays behind, unreferenced.
32
+ def start(model: nil, instructions: nil, account: nil)
33
+ response = client.create_conversation(model: model, instructions: instructions, **account_scope(account))
34
+ changed_by_sidecar
35
+ conversation(response.fetch("id"))
36
+ end
37
+
38
+ def conversation(id)
39
+ Conversation.find(id)
40
+ end
41
+
42
+ # Every provider the sidecar knows, signed in or not, with how: {"openai" => {"type" => "oauth", "source" =>
43
+ # "OAuth"}, "anthropic" => {"type" => "api_key", "source" => "ANTHROPIC_API_KEY"}, "google" => nil, ...}.
44
+ # Includes "faux" when the sidecar's model is faux/faux-1. With an account, that account's providers, under
45
+ # their base names.
46
+ def auth(account: nil)
47
+ client.auth(**account_scope(account))
48
+ end
49
+
50
+ # The stream config.on_auth refreshes when the app's sign-ins change, or the account's:
51
+ # `turbo_stream_from DurableAgent.auth_stream(account)`.
52
+ def auth_stream(account = nil)
53
+ [ "durable_agent:auth", Account.normalize(account) ].compact.join(":")
54
+ end
55
+
56
+ # The sidecar just committed rows this thread may already have read: drop the query cache so the next read in
57
+ # the same request sees them.
58
+ def changed_by_sidecar
59
+ DurableAgent::ApplicationRecord.connection_pool.clear_query_cache
60
+ end
61
+
62
+ # The keyword arguments that scope a client call to an account: none for the app's own account.
63
+ def account_scope(account)
64
+ { account: Account.normalize(account) }.compact
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,75 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module DurableAgent
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ SIDECAR = "sidecar: bin/rails durable_agent:sidecar"
12
+ # foreman gives the first process PORT=5000; the sidecar reaches Rails at rails_url, port 3000 by default.
13
+ WEB = "web: bin/rails server -p 3000"
14
+
15
+ def self.next_migration_number(dirname)
16
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
17
+ end
18
+
19
+ def copy_migration
20
+ migration_template "create_durable_agent_tables.rb.tt", "db/migrate/create_durable_agent_tables.rb"
21
+ end
22
+
23
+ def create_initializer
24
+ template "durable_agent.rb.tt", "config/initializers/durable_agent.rb"
25
+ end
26
+
27
+ def mount_engine
28
+ routes = File.join(destination_root, "config/routes.rb")
29
+ route %(mount DurableAgent::Engine => "/durable_agent") unless File.read(routes).include?("DurableAgent::Engine")
30
+ end
31
+
32
+ def add_sidecar_process
33
+ procfile = File.join(destination_root, "Procfile.dev")
34
+
35
+ if !File.exist?(procfile)
36
+ create_file "Procfile.dev", "#{WEB}\n#{SIDECAR}\n"
37
+ else
38
+ append_to_file "Procfile.dev", "#{SIDECAR}\n", force: true unless File.read(procfile).include?(SIDECAR)
39
+ warn_about_web_port File.read(procfile)
40
+ end
41
+ end
42
+
43
+ def show_next_steps
44
+ say <<~TEXT
45
+
46
+ durable_agent is installed. Next:
47
+
48
+ bin/rails db:migrate
49
+ bin/dev # runs web and sidecar from Procfile.dev (needs foreman or overmind)
50
+
51
+ Development and test derive the sidecar token from secret_key_base. In production set one:
52
+
53
+ bin/rails credentials:edit # durable_agent:
54
+ # token: #{SecureRandom.hex(32)}
55
+
56
+ or export DURABLE_AGENT_TOKEN for both the web and the sidecar processes. If production uses
57
+ force_ssl or config.hosts, see the note at the end of config/initializers/durable_agent.rb.
58
+ TEXT
59
+ end
60
+
61
+ private
62
+ def warn_about_web_port(procfile)
63
+ web = procfile[/^web:.*$/]
64
+ if web && web !~ /\s(-p|--port)[\s=]?\d+/
65
+ say <<~TEXT, :yellow
66
+
67
+ Procfile.dev's web process sets no port: foreman runs it on PORT=5000, but the sidecar calls this app at
68
+ config.rails_url, http://127.0.0.1:3000 by default. Change the line to `#{WEB}`,
69
+ or set DURABLE_AGENT_RAILS_URL=http://127.0.0.1:5000.
70
+ TEXT
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,120 @@
1
+ # The first nine tables are pi-durable's own storage schema (version 1) under the durable_agent_ prefix: the
2
+ # sidecar runs pi-durable's storage core over them, so names, columns and indexes must match it. Rails owns the
3
+ # DDL and the sidecar never runs any; the sidecar is the only writer, Rails reads. Ids and singletons are pi's: the
4
+ # sidecar supplies every one, so the default primary keys are only a type (an INTEGER PRIMARY KEY rowid alias on
5
+ # SQLite, as in pi-durable's own DDL; bigserial on Postgres, its sequence unused). created_at is extra: pi-durable's
6
+ # inserts name their columns, so the database's default fills it. JSON lives in text columns, never jsonb, which
7
+ # rejects \u0000 that a model may produce. No foreign keys: pi-durable writes the rows of one commit in its own
8
+ # order. No data: the sidecar seeds the singletons at boot.
9
+ class CreateDurableAgentTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
10
+ def change
11
+ create_table :durable_agent_durable_schema, primary_key: :singleton do |t| # one row, singleton = 1
12
+ t.integer :version, null: false
13
+ end
14
+
15
+ create_table :durable_agent_durable_metadata, primary_key: :singleton do |t|
16
+ t.text :next_id, null: false # pi stores it as text: ids can exceed 2^53 in principle
17
+ t.bigint :next_seq, null: false
18
+ end
19
+
20
+ create_table :durable_agent_record_ids do |t|
21
+ t.text :record_type, null: false # conversation | entry | task | submission | document
22
+ end
23
+
24
+ create_table :durable_agent_conversations do |t|
25
+ t.bigint :owner_conversation_id
26
+ t.bigint :owner_task_id
27
+ t.text :record, null: false # ConversationRecord JSON: {id, parent?: {conversationId, at}, owner?, ...}
28
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
29
+ t.index [ :owner_conversation_id, :id ], name: "durable_agent_conversations_by_owner_conversation"
30
+ t.index [ :owner_task_id, :id ], name: "durable_agent_conversations_by_owner_task"
31
+ end
32
+
33
+ create_table :durable_agent_entries do |t|
34
+ t.bigint :conversation_id, null: false
35
+ t.bigint :head
36
+ t.bigint :commit_seq, null: false
37
+ t.text :record, null: false # EntryRecord JSON: {id, conversationId, kind, data?, model?, head?, byTaskId?}
38
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
39
+ t.index [ :conversation_id, :id ], order: { id: :desc }, name: "durable_agent_entries_by_conversation"
40
+ t.index [ :conversation_id, :id ], order: { id: :desc }, name: "durable_agent_entry_heads_by_conversation", where: "head IS NOT NULL"
41
+ end
42
+
43
+ create_table :durable_agent_tasks do |t|
44
+ t.bigint :conversation_id, null: false
45
+ t.text :kind, null: false # JSON-encoded string, e.g. "\"pi.run\""
46
+ t.text :status, null: false # pending | running | waiting | completing | terminal
47
+ t.integer :abort_requested, null: false # 0 | 1
48
+ t.integer :background, null: false # 0 | 1
49
+ t.text :record, null: false
50
+ t.index [ :status, :id ], name: "durable_agent_tasks_by_status"
51
+ t.index [ :conversation_id, :id ], name: "durable_agent_tasks_by_conversation"
52
+ t.index [ :kind, :id ], name: "durable_agent_tasks_by_kind"
53
+ t.index [ :abort_requested, :id ], name: "durable_agent_tasks_by_abort_requested"
54
+ t.index [ :background, :id ], name: "durable_agent_tasks_by_background"
55
+ end
56
+
57
+ create_table :durable_agent_submissions do |t|
58
+ t.bigint :conversation_id, null: false
59
+ t.text :request_id # JSON-encoded string, e.g. "\"42\""; NULL when the harness made it
60
+ t.text :status, null: false # queued | placed | done | unanswered
61
+ t.text :record, null: false # SubmissionRecord JSON: {id, conversationId, requestId?, type, status, entry?, answer?, reason?, detail?}
62
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
63
+ t.index [ :conversation_id, :request_id ], name: "durable_agent_submissions_by_request"
64
+ t.index [ :conversation_id, :id ], name: "durable_agent_submissions_by_conversation"
65
+ t.index [ :status, :id ], name: "durable_agent_submissions_by_status"
66
+ end
67
+
68
+ create_table :durable_agent_documents do |t|
69
+ t.text :kind, null: false # JSON-encoded string, e.g. "\"pi.live\""
70
+ t.integer :family, null: false # 0 = unkeyed, 1 = keyed
71
+ t.text :key_value, null: false # JSON-encoded key, "\"\"" when unkeyed
72
+ t.text :scope_kind, null: false # session | conversation | task
73
+ t.bigint :owner_id, null: false # 0, the conversation id, or the task id
74
+ t.bigint :created_at, null: false # commit seq, not a time
75
+ t.bigint :retired_at # commit seq, not a time
76
+ t.text :record, null: false # DocumentRecord JSON: {id, kind, scope: {kind, conversationId?|taskId?}, key?, history, fork?, createdAt, retiredAt?}
77
+ t.index [ :kind, :scope_kind, :owner_id, :family, :key_value, :created_at, :retired_at ], order: { created_at: :desc },
78
+ name: "durable_agent_documents_by_address"
79
+ t.index [ :scope_kind, :owner_id, :id ], name: "durable_agent_documents_by_scope"
80
+ t.index [ :scope_kind, :owner_id, :kind, :id ], name: "durable_agent_documents_by_scope_kind"
81
+ end
82
+
83
+ create_table :durable_agent_document_revisions, primary_key: [ :document_id, :seq ] do |t|
84
+ t.bigint :document_id, null: false
85
+ t.bigint :seq, null: false
86
+ t.text :kind, null: false # base | delta
87
+ t.bigint :version, null: false
88
+ t.text :content, null: false # base: the value as JSON; delta: chord ops as JSON
89
+ t.index [ :document_id, :kind, :seq ], order: { seq: :desc }, name: "durable_agent_document_revisions_by_kind"
90
+ end
91
+
92
+ # Written by the sidecar through its database facade.
93
+ create_table :durable_agent_reports do |t|
94
+ t.text :type, null: false
95
+ t.bigint :conversation_id
96
+ t.bigint :task_id
97
+ t.text :task_kind
98
+ t.text :extension
99
+ t.text :message, null: false
100
+ t.text :info, null: false # JSON text
101
+ t.datetime :created_at, null: false
102
+ t.index [ :conversation_id, :id ]
103
+ end
104
+
105
+ # Provider sign-ins, encrypted by the sidecar with a key derived from DURABLE_AGENT_TOKEN. Rails has no model
106
+ # for this table and never decrypts it; connection status comes from the sidecar's GET /auth.
107
+ create_table :durable_agent_credentials, id: false do |t|
108
+ t.text :provider_id, primary_key: true
109
+ t.text :credential, null: false # the encrypted envelope as JSON text
110
+ t.datetime :updated_at, null: false
111
+ end
112
+
113
+ # device_id and schema_version, written by the sidecar.
114
+ create_table :durable_agent_settings, id: false do |t|
115
+ t.text :key, primary_key: true
116
+ t.text :value, null: false
117
+ t.datetime :updated_at, null: false
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,36 @@
1
+ DurableAgent.configure do |config|
2
+ # The default model, "provider/modelId". Unset, it is ENV["DURABLE_AGENT_MODEL"], then "openai/gpt-6.1-sol", so
3
+ # `DURABLE_AGENT_MODEL=faux/faux-1 bin/dev` runs the scripted offline model. Setting it here wins over the variable.
4
+ # config.model = "openai/gpt-6.1-sol"
5
+
6
+ # Tools are DurableAgent::Tool subclasses. Give their names as strings so code reloading keeps working.
7
+ # config.tools = %w[ LookupInvoice ]
8
+
9
+ # Where the sidecar listens, and where it reaches this app for tools and change events.
10
+ # config.host = "127.0.0.1"
11
+ # config.port = 4010
12
+ # config.rails_url = "http://127.0.0.1:3000"
13
+
14
+ # The shared secret. Defaults to credentials.durable_agent.token, then ENV["DURABLE_AGENT_TOKEN"], and in
15
+ # development and test to a value derived from secret_key_base.
16
+ # config.token = "..."
17
+
18
+ # The models' base class. If ApplicationRecord uses connects_to (roles, shards), inherit from it.
19
+ # config.parent_record_class = "ApplicationRecord"
20
+
21
+ # Called in this app's web process when the sidecar reports a change. The defaults refresh the pages subscribed
22
+ # with `turbo_stream_from conversation`; reports without a conversation refresh `turbo_stream_from
23
+ # "durable_agent:reports"`; sign-ins and sign-outs refresh `turbo_stream_from "durable_agent:auth"`.
24
+ # config.on_change = ->(conversation) { Turbo::StreamsChannel.broadcast_refresh_to(conversation) }
25
+ # config.on_report = ->(report) { Rails.logger.warn(report.message) }
26
+ # on_auth gets provider ids, scoped for accounts ("openai", "openai@u42"); its default also refreshes
27
+ # `turbo_stream_from DurableAgent.auth_stream(account)` for each account named.
28
+ # config.on_auth = ->(providers) { Turbo::StreamsChannel.broadcast_refresh_to("durable_agent:auth") }
29
+ end
30
+
31
+ # In production the sidecar calls this app over plain HTTP at rails_url (loopback by default). With
32
+ # config.force_ssl, keep /durable_agent out of the HTTPS redirect; with config.hosts set, allow the sidecar's
33
+ # host. In config/environments/production.rb:
34
+ #
35
+ # config.ssl_options = { redirect: { exclude: ->(request) { request.path.start_with?("/durable_agent") } } }
36
+ # config.hosts << "127.0.0.1"
@@ -0,0 +1,6 @@
1
+ namespace :durable_agent do
2
+ desc "Run the durable_agent sidecar with settings derived from this app"
3
+ task sidecar: :environment do
4
+ DurableAgent::Sidecar.exec
5
+ end
6
+ end