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
data/DESIGN.md ADDED
@@ -0,0 +1,614 @@
1
+ # durable_agent — design and contract
2
+
3
+ pi-durable, a durable agent runtime written in TypeScript, packaged as one Rails gem. The gem contains a Rails
4
+ engine and the sidecar process that runs the agents. An app installs the gem, runs one migration, and starts one
5
+ extra process. State lives in the app's own database, Postgres or SQLite, and Rails reads it with ordinary
6
+ models. `docs/design.html` is the illustrated version of this document; this file is the normative contract both
7
+ halves are implemented against.
8
+
9
+ ## Shape
10
+
11
+ ```
12
+ browser ◀─Turbo─ Puma ◀─POST /durable_agent/events─ sidecar (node)
13
+ │ │
14
+ │ reads durable_agent_* tables │ pi-durable Harness over a storage adapter
15
+ ▼ ▼
16
+ the app's database (Postgres or SQLite) ◀─┘
17
+ ▲
18
+ └─ Rails ─HTTP commands─▶ sidecar; sidecar ─GET/POST /durable_agent/tools─▶ Rails
19
+ ```
20
+
21
+ - One sidecar per app. It owns a pi-durable Harness whose `Storage` is pi-durable's portable SQLite storage
22
+ core (`SqliteStorage` from `@earendil-works/pi-durable/storage/sqlite`) running over a database adapter:
23
+ pi-durable's own `openNodeSqliteDatabase` for SQLite, the gem's `PostgresDatabase` for Postgres. Both open
24
+ the tables under the prefix `durable_agent_`.
25
+ - Rails owns the schema. The install migration creates every table on either adapter; the sidecar never runs
26
+ DDL and refuses to boot when the tables are missing.
27
+ - The sidecar is the only writer of every `durable_agent_*` table. Rails models are read-only.
28
+ - Rails sends commands to the sidecar over HTTP. After each commit that touches a conversation the sidecar
29
+ posts a change event to Rails; Rails calls a configurable callback whose default broadcasts a Turbo refresh
30
+ from inside Puma, so the stock `async` cable adapter works in development.
31
+ - The sidecar calls into Rails for tool definitions, tool execution and change events. Nothing else.
32
+ - SQLite suits development, test and single-host production. Postgres suits everything else.
33
+
34
+ ## Names
35
+
36
+ | Thing | Name |
37
+ |---|---|
38
+ | Gem / module | `durable_agent` / `DurableAgent`, isolated engine mounted at `/durable_agent` |
39
+ | Harness tables (pi-durable's) | `durable_agent_conversations`, `_entries`, `_tasks`, `_submissions`, `_documents`, `_document_revisions`, `_record_ids`, `_durable_schema`, `_durable_metadata` |
40
+ | Gem tables | `durable_agent_reports`, `_credentials`, `_settings` |
41
+ | Schema versions | `durable_agent_durable_schema.version` is pi-durable's (1 for pi-durable 1.3.0); `durable_agent_settings` row `schema_version` is the gem's (2); `GET /health` reports both |
42
+ | Sidecar | `sidecar/` (TypeScript), bundled to `sidecar/dist/sidecar.js` by `rake build`, shipped in the gem |
43
+ | Env prefix | `DURABLE_AGENT_*`, read by both halves |
44
+ | Account | an app-chosen string (`[A-Za-z0-9_.-]{1,64}`, typically a user id) naming whose provider credentials a conversation runs on; `openai@u42` is the account `u42`'s OpenAI provider id, `openai@u42/gpt-6.1-sol` its model ref; no `@` is the app's own account |
45
+
46
+ ## Schema
47
+
48
+ Created by `lib/generators/durable_agent/install/templates/create_durable_agent_tables.rb.tt` with adapter-neutral
49
+ Rails DDL; `test/dummy/db/migrate` carries the same file. The first nine tables are pi-durable 1.3.0's SQLite
50
+ schema (`storage/sqlite/migrations.js`, version 1) rendered for Rails under the prefix: the same table names,
51
+ columns, indexes and partial index, so pi-durable's storage core runs over them unchanged. Deliberate
52
+ differences from pi-durable's own DDL: no `STRICT`, no `json_valid` checks, `record` and `content` are `text`
53
+ holding JSON (never `jsonb`, which rejects `\u0000` and would turn a model output into a fatal commit), and
54
+ `created_at` on conversations, entries and submissions. Ids and singletons use Rails' default primary key:
55
+ `integer PRIMARY KEY AUTOINCREMENT` on SQLite, a rowid alias as in pi-durable's own `INTEGER PRIMARY KEY`;
56
+ `bigserial` on Postgres, whose sequence goes unused because the sidecar supplies every id (and always writes
57
+ singleton `1`). `created_at` is `DEFAULT CURRENT_TIMESTAMP`: pi-durable's inserts name their columns and its
58
+ submission upsert updates named columns only, so the database fills it once, at insert (UTC on both: SQLite's
59
+ `CURRENT_TIMESTAMP` is UTC, and the sidecar's Postgres connections run with `TimeZone=UTC`). Because both
60
+ adapters dump these keys the same way, `schema.rb` loads to the migration's schema on either.
61
+
62
+ ```ruby
63
+ create_table :durable_agent_durable_schema, primary_key: :singleton do |t|
64
+ t.integer :version, null: false
65
+ end
66
+ create_table :durable_agent_durable_metadata, primary_key: :singleton do |t|
67
+ t.text :next_id, null: false # pi stores it as text: ids may exceed 2^53 in principle
68
+ t.bigint :next_seq, null: false
69
+ end
70
+ create_table :durable_agent_record_ids do |t|
71
+ t.text :record_type, null: false # conversation | entry | task | submission | document
72
+ end
73
+ create_table :durable_agent_conversations do |t|
74
+ t.bigint :owner_conversation_id
75
+ t.bigint :owner_task_id
76
+ t.text :record, null: false # ConversationRecord JSON: {id, parent?: {conversationId, at}, owner?, ...}
77
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
78
+ t.index [ :owner_conversation_id, :id ], name: "durable_agent_conversations_by_owner_conversation"
79
+ t.index [ :owner_task_id, :id ], name: "durable_agent_conversations_by_owner_task"
80
+ end
81
+ create_table :durable_agent_entries do |t|
82
+ t.bigint :conversation_id, null: false
83
+ t.bigint :head
84
+ t.bigint :commit_seq, null: false
85
+ t.text :record, null: false # EntryRecord JSON: {id, conversationId, kind, data?, model?, head?, byTaskId?}
86
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
87
+ t.index [ :conversation_id, :id ], order: { id: :desc }, name: "durable_agent_entries_by_conversation"
88
+ t.index [ :conversation_id, :id ], order: { id: :desc }, name: "durable_agent_entry_heads_by_conversation", where: "head IS NOT NULL"
89
+ end
90
+ create_table :durable_agent_tasks do |t|
91
+ t.bigint :conversation_id, null: false
92
+ t.text :kind, null: false # JSON-encoded string, e.g. "\"pi.run\""
93
+ t.text :status, null: false # pending | running | waiting | completing | terminal
94
+ t.integer :abort_requested, null: false # 0 | 1
95
+ t.integer :background, null: false # 0 | 1
96
+ t.text :record, null: false
97
+ t.index [ :status, :id ], name: "durable_agent_tasks_by_status"
98
+ t.index [ :conversation_id, :id ], name: "durable_agent_tasks_by_conversation"
99
+ t.index [ :kind, :id ], name: "durable_agent_tasks_by_kind"
100
+ t.index [ :abort_requested, :id ], name: "durable_agent_tasks_by_abort_requested"
101
+ t.index [ :background, :id ], name: "durable_agent_tasks_by_background"
102
+ end
103
+ create_table :durable_agent_submissions do |t|
104
+ t.bigint :conversation_id, null: false
105
+ t.text :request_id # JSON-encoded string, e.g. "\"42\""; NULL when the harness made it
106
+ t.text :status, null: false # queued | placed | done | unanswered
107
+ t.text :record, null: false # SubmissionRecord JSON: {id, conversationId, requestId?, type, status, entry?, answer?, reason?, detail?}
108
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
109
+ t.index [ :conversation_id, :request_id ], name: "durable_agent_submissions_by_request"
110
+ t.index [ :conversation_id, :id ], name: "durable_agent_submissions_by_conversation"
111
+ t.index [ :status, :id ], name: "durable_agent_submissions_by_status"
112
+ end
113
+ create_table :durable_agent_documents do |t|
114
+ t.text :kind, null: false # JSON-encoded string, e.g. "\"pi.live\""
115
+ t.integer :family, null: false # 0 = unkeyed, 1 = keyed
116
+ t.text :key_value, null: false # JSON-encoded key, "\"\"" when unkeyed
117
+ t.text :scope_kind, null: false # session | conversation | task
118
+ t.bigint :owner_id, null: false # 0, the conversation id, or the task id
119
+ t.bigint :created_at, null: false # commit seq, not a time
120
+ t.bigint :retired_at # commit seq, not a time
121
+ t.text :record, null: false # DocumentRecord JSON: {id, kind, scope: {kind, conversationId?|taskId?}, key?, history, fork?, createdAt, retiredAt?}
122
+ t.index [ :kind, :scope_kind, :owner_id, :family, :key_value, :created_at, :retired_at ], order: { created_at: :desc }, name: "durable_agent_documents_by_address"
123
+ t.index [ :scope_kind, :owner_id, :id ], name: "durable_agent_documents_by_scope"
124
+ t.index [ :scope_kind, :owner_id, :kind, :id ], name: "durable_agent_documents_by_scope_kind"
125
+ end
126
+ create_table :durable_agent_document_revisions, primary_key: [ :document_id, :seq ] do |t|
127
+ t.bigint :document_id, null: false
128
+ t.bigint :seq, null: false
129
+ t.text :kind, null: false # base | delta
130
+ t.bigint :version, null: false
131
+ t.text :content, null: false # base: the value as JSON; delta: chord ops as JSON
132
+ t.index [ :document_id, :kind, :seq ], order: { seq: :desc }, name: "durable_agent_document_revisions_by_kind"
133
+ end
134
+
135
+ create_table :durable_agent_reports do |t| # written by the sidecar through its database facade
136
+ t.text :type, null: false
137
+ t.bigint :conversation_id
138
+ t.bigint :task_id
139
+ t.text :task_kind
140
+ t.text :extension
141
+ t.text :message, null: false
142
+ t.text :info, null: false # JSON text
143
+ t.datetime :created_at, null: false
144
+ t.index [ :conversation_id, :id ]
145
+ end
146
+ create_table :durable_agent_credentials, id: false do |t|
147
+ t.text :provider_id, primary_key: true
148
+ t.text :credential, null: false # the encrypted envelope as JSON text
149
+ t.datetime :updated_at, null: false
150
+ end
151
+ create_table :durable_agent_settings, id: false do |t|
152
+ t.text :key, primary_key: true
153
+ t.text :value, null: false
154
+ t.datetime :updated_at, null: false
155
+ end
156
+ ```
157
+
158
+ No foreign keys: pi-durable writes rows in its own order inside one transaction. The migration contains no
159
+ data; the sidecar seeds the two singleton rows at boot, so a database loaded from `schema.rb` works.
160
+ `sidecar/sql/schema.sqlite.sql` and `schema.postgres.sql` are SQL renderings of the same tables for the
161
+ sidecar's tests. A Ruby test runs the migration and the matching file against fresh databases of each adapter
162
+ and compares tables, primary keys, column types, nullability and defaults, and index definitions (name,
163
+ columns, order, predicate, uniqueness); it also checks that the test database loaded from `schema.rb` equals the
164
+ migration's.
165
+
166
+ Indexed strings (`tasks.kind`, `documents.kind`, `documents.key_value`, `submissions.request_id`) hold the
167
+ JSON encoding JavaScript's `JSON.stringify` produces, which escapes only `"`, `\` and control characters.
168
+ Rails encodes them with `DurableAgent::IndexedString` (`JSON.generate`), never `to_json`, which escapes `&`, `<`,
169
+ `>` and U+2028 and would find nothing.
170
+
171
+ ## Sidecar
172
+
173
+ ### Storage layer (`sidecar/src`)
174
+
175
+ - `db/index.ts`: `type Dialect = "postgres" | "sqlite"`; `interface Db extends SqliteDatabase { readonly dialect }`
176
+ (`SqliteDatabase` is pi-durable's facade: `exec`, `run`, `get`, `all`, `transaction`, `close`, `?`
177
+ placeholders); `openDatabase(url)` picks the adapter by scheme (`postgres:`/`postgresql:`, `sqlite:`/`sqlite3:`).
178
+ - `db/sqlite.ts`: pi-durable's `openNodeSqliteDatabase` (WAL, `synchronous = NORMAL`, `BEGIN IMMEDIATE`, the
179
+ same settings Rails 8 uses, so Puma and the sidecar share one file) behind a serial queue of its own.
180
+ `node:sqlite` waits for a lock synchronously, stalling the event loop, so its busy timeout is 100 ms
181
+ (`SQLITE_BUSY_TIMEOUT_MS`) and the real wait is asynchronous: a `transaction` whose `BEGIN IMMEDIATE` fails
182
+ with `SQLITE_BUSY` before the callback has run, and a `run`/`get`/`all` that fails busy (an autocommit statement
183
+ that has not run), are retried with backoff (25 ms doubling to 500 ms) for up to `SQLITE_BUSY_BUDGET_MS`
184
+ (30 s), then rejected with "SQLite database is locked: another writer held the write lock for more than …".
185
+ A busy error after the callback started, and `exec` (a script may have partly committed), are not retried.
186
+ Later operations wait behind the retrying one. Operational rule: no other writer (Puma, a console, a backup) may
187
+ hold the SQLite write lock longer than 30 s, or the session fails.
188
+ - `db/postgres.ts`: `PostgresDatabase` over a `pg.Pool` (max 2). `int8` is parsed to a JS number (ids and seqs
189
+ are safe integers; `next_id` is text). Every operation runs through one serial queue, as pi-durable's node
190
+ adapter does: a `transaction` checks out a client, `BEGIN`s, runs the callback on an executor bound to that
191
+ client, then `COMMIT`s or `ROLLBACK`s and rethrows; everything else waits. pg-pool drops its idle `error`
192
+ listener on checkout, so the checked-out client carries the adapter's own `error` listener from checkout to
193
+ release: a dropped connection rejects the pending query and every later one (the transaction rejects with the
194
+ connection error, without a `ROLLBACK`), and the client is released broken so the pool discards it; it is never
195
+ an uncaught exception. A transaction rejected this way may still have committed, when the connection died
196
+ between the server's `COMMIT` and its acknowledgement; the Harness treats the rejection as fatal, and the
197
+ restarted sidecar resumes from whatever the database holds, so both outcomes are consistent. Single statements go through `pool.query`, which guards its checkout the same way.
198
+ Retries, because pi-durable treats a storage error as fatal to the session: a checkout or `BEGIN` that fails
199
+ with a connection-class error (`ECONNRESET`, `57P01`, `08*`, "Connection terminated") is retried once, since
200
+ nothing has run; outside a transaction only a statement that starts with `SELECT` (after leading comments) is
201
+ sent once more, because a lost response may hide a committed write. `INSERT`/`UPDATE`/`DELETE`, CTEs and `exec`
202
+ are never repeated, and nothing inside a transaction is. Connections run with `TimeZone=UTC` so
203
+ `CURRENT_TIMESTAMP` matches Rails' timestamp columns. The pool logs idle-client errors.
204
+ - `db/translate.ts`: the storage core speaks SQLite; `toPostgres(sql)` turns `?` into `$1, $2, …` (never inside
205
+ a literal), `INSERT OR IGNORE INTO …` into `INSERT INTO … ON CONFLICT DO NOTHING` (before any `RETURNING`),
206
+ drops ` STRICT` and `CHECK (json_valid(x))`. `excluded.col`, `ON CONFLICT(id) DO UPDATE`, partial indexes and
207
+ `LIMIT ?` are already valid Postgres. Memoized by SQL text.
208
+ - `storage.ts`: `openHarnessStorage(db)` checks that `durable_agent_conversations` exists (else `MissingSchema`,
209
+ "run bin/rails db:migrate"), seeds `durable_agent_durable_schema (1, CURRENT_SQLITE_SCHEMA_VERSION)` and
210
+ `durable_agent_durable_metadata (1, '2', 1)` with `ON CONFLICT DO NOTHING`, refuses a stored version lower than
211
+ pi-durable's (a gem upgrade ships the Rails migration and bumps the row), and returns
212
+ `SqliteStorage.open(db, { prefix: "durable_agent_" })`, which finds the version current and runs no DDL. One
213
+ `CREATE TABLE IF NOT EXISTS` for the version table still reaches the database, so on Postgres the role needs
214
+ `CREATE` on the schema. Closing the storage closes the database handle.
215
+ - `settings.ts`: `installationDeviceId`, `recordSchemaVersion` (`"2"`, do nothing if present),
216
+ `readSchemaVersion`, `readHarnessSchemaVersion`, `SCHEMA_VERSION = 2`. `checkSchemaVersion` runs first at boot:
217
+ it records the version on a fresh database, refuses to boot when the stored version is greater than
218
+ `SCHEMA_VERSION` ("the database is newer than this gem"), and logs a warning ("run bin/rails db:migrate") but
219
+ boots when it is lower.
220
+ - `credentials.ts`: `DbCredentialStore(db, token)`, pi-ai's `CredentialStore` over the facade. Each credential is
221
+ stored as `{"enc": 1, "type", "iv", "tag", "data"}`: AES-256-GCM with the provider id as associated data,
222
+ keyed by HKDF-SHA256(token, salt `durable_agent_credentials`, info `v1`). `type` stays clear for `GET /auth`.
223
+ Dumps and replicas hold ciphertext; the sidecar holds the key, so anyone with the database and the token can
224
+ decrypt; Rails never decrypts. Rotating the token makes stored sign-ins unreadable: the sidecar logs a warning
225
+ per provider and treats them as absent until the user signs in again. Plaintext rows are rewritten encrypted on
226
+ the next modify. Modifications to one provider are serialized. Rows are keyed by provider id, scoped or not:
227
+ `openai` is the app's credential, `openai@u42` the account `u42`'s.
228
+ - `accounts.ts`: accounts as provider ids. `scopedProviderId("openai", "u42")` is `openai@u42`;
229
+ `parseProviderId` splits it back; `ensureAccountProvider(models, base, account)` registers a clone of the base
230
+ provider (the same factory, `openaiProvider()`, `anthropicProvider()` or faux, with the scoped id) when
231
+ `models.getProvider` does not know it, so the catalog, auth methods and request code are the base provider's and
232
+ only the credential row differs. At boot every scoped provider id in `durable_agent_credentials` is registered;
233
+ `POST /conversations` and the auth routes register on demand. pi-ai keys the credential store, `login`, `logout`
234
+ and `checkAuth` by provider id, and a conversation pins its provider in `pi.agent`, so a conversation started on
235
+ `openai@u42` resolves that account's credential on every request, including after a restart. The clone's models
236
+ carry the scoped id, since pi-ai routes a request and its auth by `model.provider`; the clone hands each
237
+ request to the base provider with the base id again, because the request code switches on it (a Sign in with
238
+ ChatGPT token is recognised for `provider === "openai"` only), so assistant entries record `provider: "openai"`
239
+ while `pi.agent` keeps `openai@u42`. An account provider resolves its own stored credential only: the
240
+ environment (`OPENAI_API_KEY`) and credential files are the app's, never an account's. An account whose
241
+ credential is gone settles its runs `unanswered` with reason `model_error` ("Provider is not configured").
242
+ - `reports.ts`: `createReporter(db, events)` stores Harness reports (faulted tasks, hook errors, a failed session)
243
+ with `RETURNING id` and `CURRENT_TIMESTAMP`, then `events.report(id)`. pi-durable 1.3.0 calls `onReport(error)`
244
+ with one argument; the `info` of newer releases is read when present. The reporter tracks its inserts in flight;
245
+ `reporter.settled()` waits for all of them and their `events.report`, and shutdown awaits it before closing the
246
+ Harness, so a report raised just before a signal is stored and delivered.
247
+
248
+ SQL in `settings.ts`, `credentials.ts` and `reports.ts` is written once in the SQLite dialect and translated on
249
+ Postgres like the core's.
250
+
251
+ ### Documents are stored as bases (`documents.ts`)
252
+
253
+ pi-durable stores a document as a base revision plus chord deltas and checkpoints a new base only when the
254
+ document's definition says so (`pi.live`: when idle; `pi.inbox`: when empty). Rails would have to replay chord
255
+ deltas to read a streaming `pi.live`. `withBaseDocuments(inner)` wraps `commit(writes)`: for each
256
+ `document.change` carrying a delta whose document keeps only its current value (the storage core's own
257
+ `isCurrentOnly`: `scope.kind !== "conversation" || history === "latest"`, so every session and task document and
258
+ every `latest` conversation document, which covers every `pi.*` document Rails reads: live, inbox, usage, provider;
259
+ `pi.agent` is `rewindable` but always checkpoints) it reads `inner.document(id, "current")`, applies the ops with `apply` from `@earendil-works/chord/delta` on a structured
260
+ clone, and replaces the write with `{kind: "base", version, value}`. The storage core deletes the previous
261
+ revisions of a current-only document on a base, so such a document always has exactly one revision row. Commits
262
+ through the wrapper are serialized so the read and the commit cannot interleave. Deltas on `rewindable`
263
+ conversation documents pass through. The Harness sees the same value and version either way. A failed read of the
264
+ current value fails the commit: without it there is no base to write.
265
+
266
+ The wrapper also resolves, before the commit and inside the same serialized step, the conversation owning each
267
+ `document.change` and `document.retire` (`commitWithOwners`): from a cache filled by creates and copies, from the
268
+ rewrite's own read of the record, or else from `inner.document`. A failed owner read is logged at `warn` and that
269
+ document is left out of the change event; the commit goes ahead.
270
+
271
+ The rule Rails relies on: **the current value of a document is the content of its newest `base` revision, and no
272
+ `delta` follows it for any document Rails reads.** If one does, `DurableAgent::Document#value` raises
273
+ `DurableAgent::Document::HistoryNotSupported`.
274
+
275
+ ### Change events (`events.ts`)
276
+
277
+ `ChangeTracker` wraps `commit(writes)` too, and the change path never decides a commit's fate: every owner is known
278
+ before `inner.commit` runs, and nothing after it can reject. It announces the conversation ids touched:
279
+ `conversation` writes, `entry` and `submission` writes (`value.conversationId`), `document.create`/`document.copy`
280
+ with a conversation scope, and the owners of `document.change`/`document.retire` that the base-document wrapper
281
+ resolved before the commit (see above). `task` writes are ignored. Queuing the ids runs after the commit inside a
282
+ `try` that logs at `warn` and returns the commit's seq regardless.
283
+
284
+ `Events` debounces for 100 ms (a steady stream still posts every 100 ms) and does
285
+ `POST {RAILS_URL}/durable_agent/events` with the bearer token and `{"conversations": [7, 9], "reports": [3],
286
+ "auth": ["openai"]}`. `auth` names the provider ids whose credential changed (a login reached `done`, an API key was stored, or
287
+ `DELETE /auth/:provider` signed one out), scoped when an account's: `["openai", "openai@u42"]`. It is left out
288
+ when there are none.
289
+ A non-2xx or network error is retried once after 1 s, then logged at `warn` and dropped: events are a hint, the
290
+ state is in the database, and the next event or page load shows it. Unset `DURABLE_AGENT_RAILS_URL` disables
291
+ events with one boot log line. `flush()` is awaited on shutdown, after the Harness has closed.
292
+
293
+ ### Boot and shutdown (`main.ts`, `harness.ts`)
294
+
295
+ Open the database from `DATABASE_URL` → `openSidecar(db, config)` (`harness.ts`): check the gem schema version →
296
+ credential store and models → account providers from the credentials table → device id → logins → Harness over
297
+ `ChangeTracker(withBaseDocuments(openHarnessStorage(db)))` → ensure the root conversation; then `main.ts` serves →
298
+ load tools (awaited, with the bounded boot retry; a failure or an unset Rails URL falls through with no tools) →
299
+ log "resuming with N tools" → `resume()`. The sidecar's tests boot through the same `openSidecar`. Every step of
300
+ `openSidecar` runs inside one handler: a missing table at any of them (SQLite "no such table", Postgres `42P01`,
301
+ also as a cause) is reported as `MissingSchema` ("run bin/rails db:migrate") and the process exits 1; any other
302
+ boot error exits 1 too. Until
303
+ `resume()` has run, `/health` reports `resumed: false` and submissions and aborts answer `503 {error: "starting"}`,
304
+ because either would start pi-durable's scheduler before the tools are installed.
305
+
306
+ Supervision: main watches `harness.closed`. When the session ends with `{reason: "failed", error}` (pi-durable
307
+ closes it, database included, after the first failed storage call) it logs the error and exits 1 so a supervisor
308
+ restarts the process; a clean close outside our own shutdown exits 1 as well. An uncaught exception is logged and
309
+ exits 1. Unhandled rejections are logged, not fatal.
310
+
311
+ Shutdown on SIGTERM or SIGINT: stop the server and wait up to 10 s for requests underway (then close their
312
+ connections), then `sidecar.close()`: dispose logins, await `reporter.settled()`, close the Harness (which commits
313
+ nothing new and closes the database), flush events; exit 0. The whole shutdown has a 30 s deadline, after which the process logs and
314
+ exits 1; a second SIGTERM or SIGINT during shutdown exits 1 at once. A signal during the tool load stops it and
315
+ the Harness is not resumed.
316
+
317
+ ### HTTP API v1 (`server.ts`)
318
+
319
+ JSON bodies, `Authorization: Bearer <token>` on everything except `/health`, compared in constant time. Unknown
320
+ routes 404. The login routes with an `:id` answer 404 unless that login belongs to `:provider`.
321
+
322
+ | Method | Path | Request | Response |
323
+ |---|---|---|---|
324
+ | GET | `/health` | | `200 {ok: true, schemaVersion: 2, harnessSchemaVersion: 1, database: "postgres" \| "sqlite", model: {provider, modelId}, tools: [names], resumed: boolean}`; `503 {ok: false, error}` once the Harness has closed |
325
+ | POST | `/conversations` | `{model?: {provider, modelId}, instructions?, account?}` | `201 {id}`; `422 {error, suggestions: [ids]}` for an unknown model; `422 {error: "invalid_account"}`. With `account`, the model's provider becomes `provider@account` and the conversation runs on that account's credential. |
326
+ | POST | `/conversations/:id/submissions` | `{content, request_id, when_busy?: "followUp" \| "steer" \| "reject"}` | `202 {id, status}`; `404`; `409 {error: "conversation_busy"}`; `503 {error: "starting"}` before resume |
327
+ | POST | `/conversations/:id/abort` | | `200 {ok: true}`; `503 {error: "starting"}` before resume |
328
+ | GET | `/auth?account=` | | `200 {"<provider>": {type: "oauth" \| "api_key", source} \| null, ...}` for the app's providers, or the account's, under their base names |
329
+ | PUT | `/auth/:provider/api_key?account=` | `{key}` | `200 {type: "api_key", source: "stored credential"}` stores the key, encrypted; `422 {error}` for a blank key or a provider without API-key auth |
330
+ | POST | `/auth/:provider/logins?account=` | | `201 <login>`; `422 <login>` when it failed at once |
331
+ | GET | `/auth/:provider/logins/:id` | | `200 <login>`; `404` |
332
+ | POST | `/auth/:provider/logins/:id/callback` | `{url}` (the redirect URL the browser ended on) | `200 <login>` |
333
+ | DELETE | `/auth/:provider/logins/:id` | | `200 <login>` (cancelled) |
334
+ | DELETE | `/auth/:provider?account=` | | `200 {ok: true}` (sign out) |
335
+
336
+ `:provider` is the base provider id; `account` scopes it; the login routes with an `:id` also accept the scoped
337
+ id. `<login>.provider` carries the scoped id. Every account route answers `422 {error: "invalid_account"}` for an
338
+ account outside `[A-Za-z0-9_.-]{1,64}`, and `422 {error: "unknown_provider"}` for a base provider the sidecar
339
+ cannot clone (`openai`, `anthropic` and `faux` it can). `PUT …/api_key` on a provider without API-key login
340
+ (faux) is `422 {error: "no_api_key_auth"}`. `POST /conversations` also accepts an already scoped model ref
341
+ (`{provider: "openai@u42", modelId}`) without `account`.
342
+ | POST | `/tools/reload` | | `200 {tools: [names]}` re-fetches tool definitions from Rails |
343
+
344
+ `<login>` = `{id, provider, status: "starting" | "awaiting_browser" | "exchanging" | "done" | "failed", url?, instructions?, deviceCode?: {userCode, verificationUri}, messages: [], error?}`.
345
+ Sign in with ChatGPT and the other OAuth flows also listen on `127.0.0.1:1455` for the browser redirect, which
346
+ completes the login by itself when the browser runs on the sidecar's host; otherwise the user pastes the final
347
+ URL and Rails posts it to the callback route.
348
+
349
+ Submissions are idempotent on `request_id`. The submission row is committed before the 202, so Rails can `find`
350
+ it at once. An unknown default model logs an error at boot and the sidecar keeps running (creation then returns
351
+ 422). Missing provider credentials are not an error at boot; runs settle `unanswered` with reason `no_model`
352
+ until someone signs in.
353
+
354
+ ### Environment
355
+
356
+ `bin/rails durable_agent:sidecar` derives every value from Rails configuration, which in turn defaults to the same
357
+ variables, so setting one of these in the shell moves both halves.
358
+
359
+ | Variable | Meaning | Default |
360
+ |---|---|---|
361
+ | `DATABASE_URL` | the app's primary database: `postgres://…` (plus `?host=` for a socket directory, and `sslmode`, `sslrootcert`, `sslcert`, `sslkey` and `options=-c search_path=…` when `database.yml` sets them; see `Sidecar.database_url`) or `sqlite:///absolute/path/to/development.sqlite3` | rendered from `database.yml` |
362
+ | `DURABLE_AGENT_TOKEN` | shared bearer secret; required, and a whitespace-only value counts as unset (both halves refuse it) | `DurableAgent.config.token` |
363
+ | `DURABLE_AGENT_HOST`, `DURABLE_AGENT_PORT` | bind address | `127.0.0.1`, `4010` |
364
+ | `DURABLE_AGENT_RAILS_URL` | where Rails answers `/durable_agent/tools` and `/durable_agent/events`; unset runs without tools or events | `http://127.0.0.1:3000` |
365
+ | `DURABLE_AGENT_MODEL` | default model as `provider/modelId`; `faux/faux-1` is the scripted offline model | `openai/gpt-6.1-sol` |
366
+ | `DURABLE_AGENT_WORKSPACE_ROOT` | cwd for built-in tools (unused today, keep the knob) | `storage/durable_agent` |
367
+ | `DURABLE_AGENT_NAME` | agent name shown on provider consent screens | the Rails application name |
368
+ | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | pass-through | |
369
+
370
+ ## Tools
371
+
372
+ Tools are Ruby classes; the sidecar is generic.
373
+
374
+ 1. At boot and on `POST /tools/reload` the sidecar does `GET {RAILS_URL}/durable_agent/tools` with the bearer
375
+ token and expects `200 [{name, description, parameters: <JSON Schema object>, replay: "safe" | "unsafe"}]`.
376
+ At boot a failed fetch is retried with backoff (500 ms doubling to 10 s) for up to 60 s, because `bin/dev`
377
+ starts node before Puma listens; then it logs a warning and runs with no tools until the next reload. A
378
+ successful reload ends the retry early. The HTTP server answers meanwhile, but the Harness resumes only after
379
+ this load: pi-durable re-runs an interrupted `replay: "safe"` call only when the tool is registered at the
380
+ moment the run resumes, and otherwise settles it as interrupted for good.
381
+ 2. Each definition becomes a pi-durable tool. Executing it does `POST {RAILS_URL}/durable_agent/tools/:name` with
382
+ `{arguments, conversation_id, call_id}`. `200 {result}` → the tool output is `result` when it is a string,
383
+ else `JSON.stringify(result)`. Any other status with `{error}` → an error result carrying the message.
384
+ Network failure → error result. Timeout 120 s.
385
+
386
+ ```ruby
387
+ class LookupInvoice < DurableAgent::Tool
388
+ description "Fetch one invoice by number"
389
+ param :number, :string, "Invoice number like INV-1042" # required by default
390
+ param :include_lines, :boolean, "Include line items", required: false
391
+ replay :safe # default :unsafe
392
+
393
+ def call(number:, include_lines: false)
394
+ Invoice.find_by!(number:).as_json
395
+ end
396
+ end
397
+
398
+ DurableAgent.configure { |config| config.tools = [ LookupInvoice ] }
399
+ ```
400
+
401
+ `DurableAgent::Tool.tool_name` is the underscored class name. `to_definition` returns the hash above. Param
402
+ types: `:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`. `DurableAgent::ToolsController`
403
+ (engine routes `GET /tools`, `POST /tools/:name`) authenticates the bearer token against
404
+ `DurableAgent.config.token`, returns 404 for an unknown tool, and sets `DurableAgent::Current.conversation_id` /
405
+ `call_id` during the call. Errors answer 422 `{error}`, and `error` is what the model sees: the message for
406
+ `ArgumentError` (bad arguments), `ActiveRecord::RecordNotFound` and `DurableAgent::Tool::Error` (which tools raise
407
+ with a message meant for the model); for any other `StandardError` only `"<ErrorClass> while running <tool>"`,
408
+ with the error itself sent to `Rails.error.report`, so messages carrying SQL, paths or secrets stay in the app.
409
+
410
+ ## Events endpoint
411
+
412
+ `POST /durable_agent/events` (engine route `events#create`), same bearer check as tools. Body
413
+ `{"conversations": [ids], "reports": [ids], "auth": ["openai"]}`, every key optional. For each conversation that
414
+ exists, `config.on_change.call(conversation)`; for each report, `config.on_report.call(report)`; when `auth` names
415
+ provider ids, `config.on_auth.call(providers)` once with them as given (`"openai"`, `"openai@u42"`). Unknown ids
416
+ are skipped. Callback errors go to
417
+ `Rails.error.report` and never fail the request. Responds 204.
418
+
419
+ The bearer check (tools and events) wants exactly `Authorization: Bearer <token>`: the scheme is case-sensitive,
420
+ then one space, then the token, compared in constant time. A bare token or any other form is 401.
421
+
422
+ The default callbacks broadcast Turbo refreshes from Puma: `on_change` to the conversation (`turbo_stream_from
423
+ conversation`); `on_report` to `report.conversation`, or to the stream `"durable_agent:reports"` when the report
424
+ has none (every report on pi-durable 1.3.0); `on_auth` to the stream `"durable_agent:auth"` and, for each account named, `"durable_agent:auth:<account>"`;
425
+ a settings page subscribes with `turbo_stream_from DurableAgent.auth_stream(account)`.
426
+
427
+ ## Who pays
428
+
429
+ Three arrangements. Each is a credential on an account plus a conversation started on that account; the app picks
430
+ per conversation, and can mix them (a user with a credential runs on their account, the rest on the app's).
431
+
432
+ - The app pays: one credential for everyone (`OPENAI_API_KEY`, a key stored with `connection.set_api_key`, or the
433
+ operator's Sign in with ChatGPT), conversations from `DurableAgent.start`. The operator's bill or plan carries
434
+ every user.
435
+ - Each user pays with an API key: `Connection.new("openai", account: user.id).set_api_key(key)`, conversations
436
+ from `DurableAgent.start(account: user.id)`. Usage lands on the user's own provider bill. No browser flow.
437
+ - Each user pays with their ChatGPT plan: `Connection.new("openai", account: user.id).start_login`, the user
438
+ approves in the browser, conversations from `DurableAgent.start(account: user.id)`. Usage is drawn from the plan,
439
+ under limits the user manages in ChatGPT settings; the app holds a refreshable token per user and never a key.
440
+
441
+ ## Accounts
442
+
443
+ The app decides which account a conversation runs on and what to do when an account has no credential:
444
+ `DurableAgent.auth(account:)` says, and `start` without `account:` runs on the app's. A user's sign-out
445
+ (`connection.disconnect`) leaves their running conversations settling `model_error` until they connect again. Usage
446
+ per account is the sum of `pi.usage` over that account's conversations, which the app relates to its users.
447
+
448
+ Sign in with ChatGPT is pi-ai's OpenAI OAuth: a public client registered dynamically per login, scopes
449
+ `openid profile email offline_access resource.invoke chatgpt.tokens.use.direct`, the resulting token sent to the
450
+ Responses API, usage drawn from the signed-in user's plan. Its redirect is `http://127.0.0.1:1455/auth/callback`.
451
+ When the browser runs on the sidecar's host, the sidecar's listener completes the login; a user elsewhere lands
452
+ on their own machine's port 1455 and pastes the final URL into the app, which posts it to the callback route.
453
+
454
+ ## Ruby API
455
+
456
+ ```ruby
457
+ DurableAgent.configure do |config|
458
+ config.model = "openai/gpt-6.1-sol" # "provider/modelId"; default DURABLE_AGENT_MODEL
459
+ config.tools = [ LookupInvoice ]
460
+ config.url = "http://127.0.0.1:4010" # the sidecar; built from host and port
461
+ config.host, config.port # defaults DURABLE_AGENT_HOST / DURABLE_AGENT_PORT, then 127.0.0.1:4010
462
+ config.rails_url = "http://127.0.0.1:3000" # default DURABLE_AGENT_RAILS_URL
463
+ config.token = "..." # default below
464
+ config.workspace_root, config.agent_name # defaults DURABLE_AGENT_WORKSPACE_ROOT / DURABLE_AGENT_NAME, then the table's
465
+ config.parent_record_class = "ApplicationRecord" # DurableAgent::ApplicationRecord's superclass; default "ActiveRecord::Base"
466
+ config.on_change = ->(conversation) { ... } # default: Turbo::StreamsChannel.broadcast_refresh_to(conversation)
467
+ config.on_report = ->(report) { ... } # default: refresh report.conversation, else "durable_agent:reports"
468
+ config.on_auth = ->(providers) { ... } # provider ids, scoped for accounts ("openai@u42"); default: refresh
469
+ # "durable_agent:auth" and "durable_agent:auth:<account>" for each account named
470
+ end
471
+
472
+ DurableAgent.start(model: nil, instructions: nil, account: nil) # => DurableAgent::Conversation (created on the sidecar, then found)
473
+ DurableAgent.conversation(id)
474
+ DurableAgent.auth(account: nil) # => {"openai" => {"type" => "oauth", "source" => "OAuth"},
475
+ # "anthropic" => {"type" => "api_key", "source" => "ANTHROPIC_API_KEY"}, "google" => nil, ...}
476
+ # the app's providers, or the account's under their base names; `source` is pi-ai's:
477
+ # "OAuth", "stored credential", or the environment variable it read
478
+ DurableAgent.auth_stream(account = nil) # "durable_agent:auth" or "durable_agent:auth:u42", for turbo_stream_from
479
+
480
+ conversation.say("Hello", request_id: message.id, when_busy: nil) # => DurableAgent::Submission (persisted)
481
+ conversation.abort
482
+ conversation.transcript # Array of visible entries, oldest first
483
+ conversation.entries / submissions / tasks / reports / documents
484
+ conversation.document("pi.live") / live / busy? / streaming_message / running_tools / inbox / last_failure
485
+ conversation.model / agent / usage / usage_by_model / total_tokens / total_cost # pi.agent and pi.usage
486
+ conversation.provider / account # "openai" and "u42" from model "openai@u42/gpt-6.1-sol"; account nil for the app's
487
+ entry.message / provider / model_id / model_ref / stop_reason / error_message / usage / total_tokens / cost / duration_ms / timestamp
488
+ entry.failed? / answer? # an assistant entry whose request ended in an error or abort is no answer
489
+ conversation.parent / parent_at # from record["parent"]: the parent Conversation and the Entry it forked at
490
+ conversation.created_at / last_activity_at # the row's insert time; the newest entry's created_at, else created_at
491
+ DurableAgent::Conversation.root / top_level # the root the sidecar creates at boot (id 1); the others no task owns
492
+
493
+ DurableAgent::Connection.new("openai", account: nil) # the app's OpenAI sign-in, or account u42's
494
+ connection.connected? / subscription? / api_key? / unreachable? / error / source
495
+ connection.set_api_key(key) / start_login / login(id) / complete_login(id, url) / cancel_login(id) / disconnect
496
+ connection.account / stream_name # DurableAgent.auth_stream(account)
497
+ DurableAgent::Connection.all(account: nil)
498
+ ```
499
+
500
+ Models in `app/models/durable_agent/`, all `readonly?`, all over the tables above, inheriting
501
+ `DurableAgent::ApplicationRecord` and the `HarnessRecord` concern (table name prefix, read-only).
502
+ `DurableAgent::ApplicationRecord` inherits from `config.parent_record_class`, resolved when the models load; an
503
+ app whose `ApplicationRecord` uses `connects_to` sets it to `"ApplicationRecord"` so role and shard switching apply.
504
+ `DurableAgent.start`, `Conversation#say` and `#abort` clear the query cache once the sidecar has answered, so
505
+ `busy?`, `live` and `transcript` read its commit in the same request. `DurableAgent.start` cannot join the caller's
506
+ transaction (the sidecar commits on its own connection): when the app's own record fails to save afterwards, the
507
+ conversation remains, unreferenced.
508
+
509
+ - `Conversation`: `attribute :record, :json`; `top_level` scope is `owner_conversation_id: nil` without the root
510
+ (id 1, which the sidecar creates at boot; `Conversation.root` returns it); `created_at` is a column and
511
+ `last_activity_at` the newest entry's `created_at`, else the conversation's;
512
+ `has_many :entries / :submissions / :tasks / :reports` on `conversation_id` (own rows only; a forked
513
+ conversation's inherited entries are not followed); `documents` is `Document.current.where(scope_kind:
514
+ "conversation", owner_id: id)`; `document(kind)` returns that document's `value` or `{}`. `say` returns
515
+ `submissions.find(response["id"])`. `last_failure` is the newest `unanswered` submission whose `entry_id` is
516
+ greater than the id of the newest `pi.assistant` entry that is an answer (`stopReason` neither `error` nor
517
+ `aborted`), or any unanswered one when there is no answer yet. `model` is `"provider/modelId"` from the
518
+ `pi.agent` document; `usage`, `usage_by_model`, `total_tokens`, `total_cost` read `pi.usage`.
519
+ - `Entry`: `attribute :record, :json`; `kind`, `data`, `model`, `head`, `by_task_id` come from the record (there
520
+ are no such columns); `created_at` is a column. Scopes `chronologically` / `reverse_chronologically` on id. `HIDDEN_KINDS =
521
+ %w[pi.system pi.reset]`, `visible?`, `user?`, `assistant?`, `tool_result?`, `compaction?`, `messages`, `text`,
522
+ `tool_calls`, `thinking?`, `tool_name`, `error?`. Kind filtering happens in Ruby: `Entry.visible` and
523
+ `Entry.of_kind(*kinds)` return arrays and work on relations.
524
+ - `Submission`: `attribute :record, :json`; the `request_id` column is the JSON-encoded string, so
525
+ `Submission.for_request(id)` encodes with `IndexedString`; `created_at` is a column; `type`, `entry_id`, `answer_entry_id`, `reason`, `detail`,
526
+ `entry`, `answer` come from the record; `status` is a column with scopes `pending`, `settled`, `unanswered`;
527
+ `explanation` turns pi's reasons (`no_model`, `run_failed`, `aborted`, …) into a sentence.
528
+ - `Task`: `attribute :record, :json`; `kind` decodes the JSON column; `status`, `abort_requested?`,
529
+ `background?`, `terminal?`; scopes `of_kind`, `unfinished`.
530
+ - `Document`: `kind` and `key` decode their JSON columns; `current` scope (`retired_at: nil`); `of_kind(kind)`
531
+ encodes; `has_many :revisions` (`Document::Revision`, table `durable_agent_document_revisions`); `value` is the
532
+ parsed content of the newest `base` revision and raises `HistoryNotSupported` when a `delta` has a greater seq.
533
+ - `Report`: `attribute :info, :json`; `belongs_to :conversation, optional: true`; `latest` scope.
534
+ - Credentials and settings have no models.
535
+
536
+ Other Ruby:
537
+
538
+ - `Configuration#token`: `Rails.application.credentials.dig(:durable_agent, :token)`, then
539
+ `ENV["DURABLE_AGENT_TOKEN"]`, then in development and test a value derived from `secret_key_base` so both
540
+ halves agree with zero setup. Production with none of these raises `MissingToken` where the token is used (the
541
+ `durable_agent:sidecar` task, the engine's controllers, the client), not at boot, so `db:migrate`, `console`
542
+ and asset builds run without one. A blank token raises `MissingToken` rather than being compared.
543
+ - `DurableAgent::IndexedString.encode(value)` / `.decode(text)`: the indexed-string encoding (see Schema).
544
+ - `DurableAgent::MessageText` extracts text, tool calls and thinking from pi messages (string content or blocks
545
+ of `{type: "text" | "thinking" | "toolCall"}`).
546
+ - `DurableAgent::Client` wraps Net::HTTP. Errors: `DurableAgent::Error` (status, body), `Busy` (409),
547
+ `UnknownModel` (422 on conversations, carries `suggestions`), `Unavailable` (connection refused, reset or
548
+ timed out; `EOFError`, `IOError` or `Errno::EPIPE` from a sidecar that went away mid-response; 503 `{error:
549
+ "starting"}` before the sidecar has resumed, with the message "sidecar is starting").
550
+ - `DurableAgent::Sidecar.env` builds the environment table; `.database_url` renders the primary `database.yml`
551
+ entry (`sqlite3` → `sqlite:///<absolute path>`, relative paths resolved against `Rails.root`; `postgresql` →
552
+ `postgres://user:pass@host:port/db` plus `?host=`, `sslmode`, `sslrootcert`/`sslcert`/`sslkey` (relative paths
553
+ resolved against `Rails.root`) and `schema_search_path` as `options=-c search_path=<value>`; an explicit `url`
554
+ passes through; other adapters raise `ArgumentError`). `sslmode=require`, rendered or in an explicit URL,
555
+ reaches node as `sslmode=no-verify`: libpq's `require` encrypts without verifying the server certificate, while
556
+ pg-connection-string reads `require` as `verify-full` and would refuse the self-signed certificates many hosted
557
+ databases use; `no-verify` is its name for libpq's `require`. `verify-ca` and `verify-full` pass through.
558
+ `.command` returns `["node", <dist/sidecar.js, or src/main.ts from a
559
+ checkout>]`; `.exec` replaces the process.
560
+ - Rake task: `durable_agent:sidecar`. Package with `rake build`, which bundles the sidecar (not checked in) before
561
+ packaging; the gemspec aborts a bare `gem build` when `sidecar/dist/sidecar.js` is missing. `CHANGELOG.md` keeps
562
+ an Unreleased section.
563
+ - `DurableAgent::TestHelper` (`require "durable_agent/test_helper"`, include it in `ActiveSupport::TestCase`):
564
+ `stub_sidecar(**responses)` replaces client methods with values, callables or errors and records
565
+ `sidecar_calls`; `fake_sidecar(**responses)` answers `create_conversation`, `submit` and `abort` as the sidecar
566
+ does, committing the conversation or queued submission row first (ids from 950001, submissions idempotent on
567
+ `request_id`, 404 for an unknown conversation). The gem's own tests use it.
568
+ - Generator `durable_agent:install`: copies the migration, writes `config/initializers/durable_agent.rb` with
569
+ commented production notes for excluding `/durable_agent` from `force_ssl`'s redirect and from `config.hosts`
570
+ (the sidecar calls Rails over plain loopback HTTP), mounts the engine, adds `sidecar: bin/rails
571
+ durable_agent:sidecar` to `Procfile.dev` (creating it with `web: bin/rails server -p 3000` and `sidecar:`
572
+ otherwise; when an existing `web:` line sets no port it says that foreman runs it on 5000 while `rails_url`
573
+ defaults to 3000), and prints what it did and how to set a production token. The initializer leaves
574
+ `config.model` commented out, so `DURABLE_AGENT_MODEL` keeps working, and shows all three callbacks.
575
+ - The app brings its own database adapter gem (`pg` or `sqlite3`); the gem depends on `rails` and `turbo-rails`.
576
+
577
+ ## Tests
578
+
579
+ Both suites run on both databases.
580
+
581
+ - Ruby: Minitest with the dummy app, which is the example app (slopdep: a Nitro Kit chat UI, three tools, a
582
+ settings page; its own tests are in the suite). `test/dummy/config/database.yml` selects the adapter from
583
+ `DURABLE_AGENT_DB` (`sqlite`, the default, files under `test/dummy/storage/`; `postgres` with
584
+ `durable_agent_development` / `durable_agent_test`). `rake test` runs on SQLite, `rake test:postgres` on
585
+ Postgres, `rake test:all` both. Fixtures use ids ≥ 900000 and `record` JSON in pi-durable's shapes. Covered:
586
+ the models (document values on a base and on a base followed by a delta, the JSON-encoded columns against
587
+ hand-written `JSON.stringify` output, transcript, `last_failure`, `busy?`, timestamps, the root), the query
588
+ cache after commands (a second connection commits as the sidecar would), client errors, the tools and events
589
+ controllers (Bearer scheme, error exposure, auth events), configuration (environment defaults, blank token),
590
+ `Sidecar.database_url` on both adapters, the generator, `DurableAgent::TestHelper`, the migration against the
591
+ sidecar's SQL files on both adapters in every run and against `schema.rb`, and one round trip that boots the
592
+ real sidecar on `faux/faux-1` against the test database when `node` is available, after `/health` says
593
+ `resumed`. Cross-process reads use `ActiveRecord::Base.uncached`.
594
+ - TypeScript: `node --test`. `test/helpers.ts` opens the database from `DURABLE_AGENT_TEST_DATABASE_URL`
595
+ (default a fresh SQLite file in a temp dir) and applies the matching schema file. `npm test` runs on SQLite,
596
+ `npm run test:postgres` on Postgres. Covered: pi-durable's storage conformance cases against
597
+ `openHarnessStorage`, the translator (including pi-durable's own migration DDL), the Postgres queue, SELECT-only
598
+ retry and backend termination inside transactions, the SQLite busy wait against a second connection holding the lock,
599
+ storage open (missing schema, seeding, version refusal, a Harness on faux), the base-document wrapper (unit
600
+ cases, a task-scoped document among them, and a streaming `pi.live` that is only ever one base row equal to `watch.value.docs["pi.live"]`), the
601
+ change tracker (failing reads before and after the commit) and events (auth included) against a fake Rails,
602
+ credentials, settings (schema version newer and older), reports (`settled()`), the model check, the HTTP API
603
+ (health, readiness, login provider check), the process itself (`test/sidecar.test.ts` spawns `src/main.ts`: resume
604
+ after tools, exit 1 on a failed storage call, a second signal, `MissingSchema` for any missing table, a newer
605
+ schema), a restart that re-runs an interrupted replay-safe tool call once Rails lists the tools late,
606
+ and the tools protocol with its boot retry.
607
+
608
+ ## Conventions
609
+
610
+ - Ruby in 37signals style: fat models, thin controllers, concerns, no service objects, Minitest, fixtures.
611
+ - TypeScript: strict, ESM, `.ts` imports, Node 22.19+ (type stripping), runnable from source without a build.
612
+ - Do not commit from an agent; the orchestrator commits.
613
+ - Out of scope today: more than one sidecar, compaction/reset/task-graph routes, any UI, MCP, a Ruby port of
614
+ chord deltas, following forked conversations' inherited entries.