1use sqlx::postgres::PgPoolOptions;
22use sqlx::{Executor as _, Row as _};
23
24use super::{Error, Pool};
25
26const SCHEMA: &str = r#"
32-- Every base objectiveai table lives in the `objectiveai` schema —
33-- one namespace for what used to be split across `public` and
34-- `logs`. Plugin/tool compartments (see `db::compartment`) get
35-- readonly over exactly this schema and never touch `public`.
36CREATE SCHEMA IF NOT EXISTS objectiveai;
37
38-- `tag_groups`: explicit grouping container that lets many tags
39-- share one resolved `AgentSpec` + parent lineage. The cli's
40-- `agents tags apply` either creates a group on the fly (for the
41-- `--agent` arm) or joins the new tag into an existing group (for
42-- the `--agent-tag` arm). When any one tag in a group is picked
43-- up by a live spawn, the conduit-driven upgrade flips every tag
44-- in the group from `tag_group` to `agent_instance_hierarchy` in
45-- one UPDATE inside the read transaction — see
46-- `db::message_queue::read_pending_and_upgrade_tag`.
47CREATE TABLE IF NOT EXISTS objectiveai.tag_groups (
48 id BIGSERIAL PRIMARY KEY,
49 -- Resolved `agents::spawn::AgentSpec`; serialized
50 -- as JSONB. References are resolved at apply-time, never at
51 -- spawn-time, so this column is always inline-or-remote.
52 agent_spec JSONB NOT NULL,
53 -- The lineage prefix the spawn-by-tag will compose its AIH
54 -- against. NOT NULL: callers (CLI handlers) substitute the
55 -- cli's own `Config.agent_instance_hierarchy` when the user
56 -- omits the argument.
57 parent_agent_instance_hierarchy TEXT NOT NULL,
58 created_at BIGINT NOT NULL
59);
60
61CREATE TABLE IF NOT EXISTS objectiveai.tags (
62 name TEXT PRIMARY KEY NOT NULL,
63 -- BOUND when set: this tag resolves to a live AIH.
64 agent_instance_hierarchy TEXT,
65 -- GROUPED when set: this tag's resolution is the tag_group
66 -- row's (agent_spec, parent). Exactly one of the two columns
67 -- is non-null at any time.
68 tag_group BIGINT,
69 updated_at BIGINT NOT NULL,
70 CHECK (
71 (agent_instance_hierarchy IS NOT NULL AND tag_group IS NULL)
72 OR
73 (agent_instance_hierarchy IS NULL AND tag_group IS NOT NULL)
74 ),
75 FOREIGN KEY (tag_group) REFERENCES objectiveai.tag_groups(id) ON DELETE CASCADE
76);
77CREATE INDEX IF NOT EXISTS tags_hierarchy_idx
78 ON objectiveai.tags(agent_instance_hierarchy);
79CREATE INDEX IF NOT EXISTS tags_tag_group_idx
80 ON objectiveai.tags(tag_group);
81
82-- `laboratory_attachments`: laboratory IDs attached to an agent target.
83-- The target is EITHER an `agent_instance_hierarchy` (AIH) OR a `tag`
84-- (never both — same exclusivity CHECK as `tags`/`message_queue`). A
85-- given laboratory is attached at most once per target, enforced by a
86-- partial unique index per target column. `laboratory_id` is an opaque
87-- external identifier (no labs table, no FK).
88CREATE TABLE IF NOT EXISTS objectiveai.laboratory_attachments (
89 id BIGSERIAL PRIMARY KEY,
90 agent_instance_hierarchy TEXT,
91 tag TEXT,
92 laboratory_id TEXT NOT NULL,
93 created_at BIGINT NOT NULL,
94 CHECK (
95 (agent_instance_hierarchy IS NOT NULL AND tag IS NULL)
96 OR
97 (agent_instance_hierarchy IS NULL AND tag IS NOT NULL)
98 )
99);
100CREATE UNIQUE INDEX IF NOT EXISTS laboratory_attachments_tag_unique_idx
101 ON objectiveai.laboratory_attachments(tag, laboratory_id)
102 WHERE tag IS NOT NULL;
103CREATE UNIQUE INDEX IF NOT EXISTS laboratory_attachments_aih_unique_idx
104 ON objectiveai.laboratory_attachments(agent_instance_hierarchy, laboratory_id)
105 WHERE agent_instance_hierarchy IS NOT NULL;
106
107-- Latest continuation token per agent_instance_hierarchy. Upserted
108-- per streamed chunk by the chunk-yielder loops in `agents spawn`
109-- and `functions execute`. No GC, no history — querying it gives
110-- the single most recent continuation for that AIH.
111CREATE TABLE IF NOT EXISTS objectiveai.agent_continuations (
112 agent_instance_hierarchy TEXT PRIMARY KEY NOT NULL,
113 continuation TEXT NOT NULL,
114 updated_at BIGINT NOT NULL
115);
116
117-- Most-recent agent-completion `total_tokens` per AIH. Overwritten
118-- (last-write-wins) by the log writer every time it encounters an
119-- agent-completion chunk carrying a non-NULL usage, at any tier
120-- (standalone agent completion, or nested inside vector/function
121-- executions). A snapshot for sampling token usage over time — not a
122-- running sum.
123CREATE TABLE IF NOT EXISTS objectiveai.agent_token_usage (
124 agent_instance_hierarchy TEXT PRIMARY KEY NOT NULL,
125 total_tokens BIGINT NOT NULL
126);
127
128-- AFTER-INSERT/UPDATE trigger on `agent_token_usage`: every write
129-- emits `NOTIFY agent_token_usage_changed '<agent_instance_hierarchy>'`
130-- so `agents logs token-usage subscribe` wakes the instant an AIH's
131-- token snapshot is written. The listener re-reads and compares to a
132-- baseline, so a same-value overwrite (the writer's ON CONFLICT DO
133-- UPDATE fires this even when the number is unchanged) is filtered
134-- out client-side.
135CREATE OR REPLACE FUNCTION objectiveai.notify_agent_token_usage_changed()
136RETURNS trigger AS $$
137BEGIN
138 PERFORM pg_notify('agent_token_usage_changed', NEW.agent_instance_hierarchy);
139 RETURN NEW;
140END;
141$$ LANGUAGE plpgsql;
142CREATE OR REPLACE TRIGGER agent_token_usage_changed_notify
143AFTER INSERT OR UPDATE ON objectiveai.agent_token_usage
144FOR EACH ROW EXECUTE FUNCTION objectiveai.notify_agent_token_usage_changed();
145
146CREATE TABLE IF NOT EXISTS objectiveai.message_queue (
147 id BIGSERIAL PRIMARY KEY,
148 agent_instance_hierarchy TEXT,
149 agent_tag TEXT,
150 -- AIH of the caller who enqueued this row (sourced from
151 -- `ctx.config.agent_instance_hierarchy` at enqueue time).
152 -- Surfaced on `agents queue read pending` so callers can
153 -- audit "who asked for this" without a join.
154 sender_agent_instance_hierarchy TEXT NOT NULL,
155 -- Content rows live in `message_queue_contents` (PK
156 -- `id BIGSERIAL`, FK `message_queue_id` → here). Readers
157 -- JOIN; no denormalized JSON shadow column lives here.
158 enqueued_at BIGINT NOT NULL,
159 key TEXT,
160 -- Soft-delete marker. Rows start at TRUE and flip to FALSE
161 -- when consumed (either via the LogWriter's MessageQueue row
162 -- write or via `db::message_queue::delete_by_id`'s in-flight
163 -- lock-race-released path). Every reader filters
164 -- `WHERE active = TRUE`, so flipped rows are invisible.
165 -- Content stays around in `message_queue_contents` (the old
166 -- `ON DELETE CASCADE` chain no longer fires because we don't
167 -- DELETE).
168 active BOOLEAN NOT NULL DEFAULT TRUE,
169 CHECK (
170 (agent_instance_hierarchy IS NOT NULL AND agent_tag IS NULL)
171 OR
172 (agent_instance_hierarchy IS NULL AND agent_tag IS NOT NULL)
173 )
174);
175CREATE INDEX IF NOT EXISTS message_queue_hierarchy_idx
176 ON objectiveai.message_queue(agent_instance_hierarchy, id)
177 WHERE agent_instance_hierarchy IS NOT NULL;
178CREATE INDEX IF NOT EXISTS message_queue_tag_idx
179 ON objectiveai.message_queue(agent_tag, id)
180 WHERE agent_tag IS NOT NULL;
181-- Per-target idempotency keys. The `AND active = TRUE` clause
182-- means inactive prior rows don't count toward uniqueness — an
183-- `agents message --enqueue-with-key k` after a prior consumption
184-- inserts cleanly without UNIQUE-violating the soft-flipped row.
185CREATE UNIQUE INDEX IF NOT EXISTS message_queue_key_hierarchy_unique_idx
186 ON objectiveai.message_queue(agent_instance_hierarchy, key)
187 WHERE agent_instance_hierarchy IS NOT NULL
188 AND key IS NOT NULL
189 AND active = TRUE;
190CREATE UNIQUE INDEX IF NOT EXISTS message_queue_key_tag_unique_idx
191 ON objectiveai.message_queue(agent_tag, key)
192 WHERE agent_tag IS NOT NULL
193 AND key IS NOT NULL
194 AND active = TRUE;
195
196CREATE TABLE IF NOT EXISTS objectiveai.message_queue_contents (
197 id BIGSERIAL PRIMARY KEY,
198 message_queue_id BIGINT NOT NULL,
199 kind TEXT NOT NULL
200 CHECK (kind IN ('text','image','audio','video','file')),
201 FOREIGN KEY (message_queue_id) REFERENCES objectiveai.message_queue(id) ON DELETE CASCADE
202);
203CREATE INDEX IF NOT EXISTS message_queue_contents_parent_idx
204 ON objectiveai.message_queue_contents(message_queue_id);
205
206CREATE TABLE IF NOT EXISTS objectiveai.message_queue_texts (
207 id BIGINT PRIMARY KEY,
208 text TEXT NOT NULL,
209 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
210);
211
212CREATE TABLE IF NOT EXISTS objectiveai.message_queue_images (
213 id BIGINT PRIMARY KEY,
214 url TEXT NOT NULL,
215 detail TEXT,
216 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
217);
218
219CREATE TABLE IF NOT EXISTS objectiveai.message_queue_audios (
220 id BIGINT PRIMARY KEY,
221 data TEXT NOT NULL,
222 format TEXT NOT NULL,
223 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
224);
225
226CREATE TABLE IF NOT EXISTS objectiveai.message_queue_videos (
227 id BIGINT PRIMARY KEY,
228 url TEXT NOT NULL,
229 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
230);
231
232CREATE TABLE IF NOT EXISTS objectiveai.message_queue_files (
233 id BIGINT PRIMARY KEY,
234 file_data TEXT,
235 file_id TEXT,
236 filename TEXT,
237 file_url TEXT,
238 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
239);
240
241-- AFTER-UPDATE trigger on `message_queue.active`: every soft-flip
242-- (TRUE → FALSE) emits a `NOTIFY message_queue_inactive '<id>'`
243-- so the cli's `db::message_queue::subscribe_delivered` listener
244-- wakes up the instant a consumption flip lands. Pure native
245-- LISTEN/NOTIFY — no polling. We no longer hard-delete, so the
246-- prior AFTER DELETE trigger is gone.
247CREATE OR REPLACE FUNCTION objectiveai.notify_message_queue_inactive()
248RETURNS trigger AS $$
249BEGIN
250 IF OLD.active = TRUE AND NEW.active = FALSE THEN
251 PERFORM pg_notify('message_queue_inactive', NEW.id::text);
252 END IF;
253 RETURN NEW;
254END;
255$$ LANGUAGE plpgsql;
256CREATE OR REPLACE TRIGGER message_queue_inactive_notify
257AFTER UPDATE OF active ON objectiveai.message_queue
258FOR EACH ROW EXECUTE FUNCTION objectiveai.notify_message_queue_inactive();
259
260"#;
261
262const LOGS_SCHEMA: &str = include_str!("logs/schema.sql");
267
268const READER_GROUP: &str = r#"
278DO $$
279BEGIN
280 CREATE ROLE objectiveai_read NOLOGIN;
281EXCEPTION WHEN duplicate_object THEN
282 NULL;
283END
284$$;
285GRANT USAGE ON SCHEMA objectiveai TO objectiveai_read;
286GRANT SELECT ON ALL TABLES IN SCHEMA objectiveai TO objectiveai_read;
287ALTER DEFAULT PRIVILEGES IN SCHEMA objectiveai GRANT SELECT ON TABLES TO objectiveai_read;
288"#;
289
290pub async fn init(url: &str, database: &str) -> Result<Pool, Error> {
297 let app_url = format!(
298 "{}/{}",
299 url.trim_end_matches('/'),
300 percent_encoding::utf8_percent_encode(database, percent_encoding::NON_ALPHANUMERIC),
301 );
302
303 let admin = PgPoolOptions::new()
307 .max_connections(1)
308 .connect(url)
309 .await
310 .map_err(|e| unreachable_hint(url, e))?;
311 let exists: bool = {
312 let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)")
313 .bind(database)
314 .fetch_one(&admin)
315 .await?;
316 row.try_get::<bool, _>(0)?
317 };
318 if !exists {
319 let quoted = database.replace('"', "\"\"");
329 match admin
330 .execute(format!("CREATE DATABASE \"{quoted}\"").as_str())
331 .await
332 {
333 Ok(_) => {}
334 Err(sqlx::Error::Database(db))
341 if matches!(db.code().as_deref(), Some("42P04") | Some("23505")) => {}
342 Err(e) => return Err(e.into()),
343 }
344 }
345 admin.close().await;
346
347 let pool = PgPoolOptions::new()
360 .max_connections(8)
361 .connect(&app_url)
362 .await
363 .map_err(|e| unreachable_hint(&app_url, e))?;
364 {
365 const SCHEMA_LOCK_KEY: i64 = 0x0B7EC71AE_15CBE_AA_i64;
370 let mut conn = pool.acquire().await?;
371 sqlx::query("SELECT pg_advisory_lock($1)")
372 .bind(SCHEMA_LOCK_KEY)
373 .execute(&mut *conn)
374 .await?;
375 let apply_result: Result<(), Error> = async {
376 conn.execute(SCHEMA).await?;
377 conn.execute(LOGS_SCHEMA).await?;
378 conn.execute(READER_GROUP).await?;
379 Ok(())
380 }
381 .await;
382 let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
385 .bind(SCHEMA_LOCK_KEY)
386 .execute(&mut *conn)
387 .await;
388 apply_result?;
389 }
390
391 Ok(Pool(pool))
392}
393
394pub(crate) fn config_url(address: &str, user: &str, password: &str) -> String {
401 let user =
402 percent_encoding::utf8_percent_encode(user, percent_encoding::NON_ALPHANUMERIC);
403 let password =
404 percent_encoding::utf8_percent_encode(password, percent_encoding::NON_ALPHANUMERIC);
405 format!("postgres://{user}:{password}@{address}")
406}
407
408fn unreachable_hint(url: &str, source: sqlx::Error) -> Error {
415 Error::DbUnreachable {
416 url: redact_url_password(url),
417 source: Box::new(source),
418 }
419}
420
421fn redact_url_password(url: &str) -> String {
426 let Some(scheme_end) = url.find("://") else {
427 return url.to_string();
428 };
429 let rest = &url[scheme_end + 3..];
430 let authority = &rest[..rest.find('/').unwrap_or(rest.len())];
431 let Some(at) = authority.rfind('@') else {
432 return url.to_string();
433 };
434 let userinfo = &authority[..at];
435 let Some(colon) = userinfo.find(':') else {
436 return url.to_string();
437 };
438 format!(
439 "{}:***{}",
440 &url[..scheme_end + 3 + colon],
441 &url[scheme_end + 3 + at..],
442 )
443}
444
445#[cfg(test)]
446mod tests {
447 use super::redact_url_password;
448
449 #[test]
450 fn redact_url_password_cases() {
451 assert_eq!(
452 redact_url_password("postgresql://postgres:s3cr%40t@127.0.0.1:5432"),
453 "postgresql://postgres:***@127.0.0.1:5432",
454 );
455 assert_eq!(
456 redact_url_password("postgres://u:p@h:5432/objectiveai"),
457 "postgres://u:***@h:5432/objectiveai",
458 );
459 assert_eq!(
461 redact_url_password("postgres://postgres@127.0.0.1:5432"),
462 "postgres://postgres@127.0.0.1:5432",
463 );
464 assert_eq!(
466 redact_url_password("postgres://127.0.0.1:5432/db"),
467 "postgres://127.0.0.1:5432/db",
468 );
469 assert_eq!(redact_url_password("127.0.0.1:5432"), "127.0.0.1:5432");
471 }
472}