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-- Fires `NOTIFY tags_changed '<agent_instance_hierarchy>'` whenever a
83-- tag row bound to an AIH is written or removed, so the daemon's
84-- `/agents/instances/list` endpoint can refresh that agent's tag list. Only BOUND
85-- rows carry an AIH; GROUPED rows (`agent_instance_hierarchy` NULL) map
86-- to no `/agents/instances/list` record and are skipped. A relocation (an UPDATE that
87-- moves the AIH) changes BOTH the old and new agent's tag list, so both
88-- sides are notified when they differ.
89CREATE OR REPLACE FUNCTION objectiveai.notify_tags_changed()
90RETURNS trigger AS $$
91BEGIN
92 IF (TG_OP = 'DELETE') THEN
93 IF OLD.agent_instance_hierarchy IS NOT NULL THEN
94 PERFORM pg_notify('tags_changed', OLD.agent_instance_hierarchy);
95 END IF;
96 RETURN OLD;
97 END IF;
98 -- INSERT / UPDATE. On an AIH move, notify the vacated side too.
99 IF TG_OP = 'UPDATE'
100 AND OLD.agent_instance_hierarchy IS NOT NULL
101 AND OLD.agent_instance_hierarchy IS DISTINCT FROM NEW.agent_instance_hierarchy THEN
102 PERFORM pg_notify('tags_changed', OLD.agent_instance_hierarchy);
103 END IF;
104 IF NEW.agent_instance_hierarchy IS NOT NULL THEN
105 PERFORM pg_notify('tags_changed', NEW.agent_instance_hierarchy);
106 END IF;
107 RETURN NEW;
108END;
109$$ LANGUAGE plpgsql;
110CREATE OR REPLACE TRIGGER tags_changed_notify
111AFTER INSERT OR UPDATE OR DELETE ON objectiveai.tags
112FOR EACH ROW EXECUTE FUNCTION objectiveai.notify_tags_changed();
113
114-- `laboratory_attachments`: laboratory IDs attached to an agent target.
115-- The target is EITHER an `agent_instance_hierarchy` (AIH) OR a `tag`
116-- (never both — same exclusivity CHECK as `tags`/`message_queue`). A
117-- given laboratory is attached at most once per target, enforced by a
118-- partial unique index per target column. `laboratory_id` is an opaque
119-- external identifier (no labs table, no FK).
120CREATE TABLE IF NOT EXISTS objectiveai.laboratory_attachments (
121 id BIGSERIAL PRIMARY KEY,
122 agent_instance_hierarchy TEXT,
123 tag TEXT,
124 laboratory_id TEXT NOT NULL,
125 created_at BIGINT NOT NULL,
126 CHECK (
127 (agent_instance_hierarchy IS NOT NULL AND tag IS NULL)
128 OR
129 (agent_instance_hierarchy IS NULL AND tag IS NOT NULL)
130 )
131);
132CREATE UNIQUE INDEX IF NOT EXISTS laboratory_attachments_tag_unique_idx
133 ON objectiveai.laboratory_attachments(tag, laboratory_id)
134 WHERE tag IS NOT NULL;
135CREATE UNIQUE INDEX IF NOT EXISTS laboratory_attachments_aih_unique_idx
136 ON objectiveai.laboratory_attachments(agent_instance_hierarchy, laboratory_id)
137 WHERE agent_instance_hierarchy IS NOT NULL;
138
139-- Latest continuation token per agent_instance_hierarchy. Upserted
140-- per streamed chunk by the chunk-yielder loops in `agents spawn`
141-- and `functions execute`. No GC, no history — querying it gives
142-- the single most recent continuation for that AIH.
143CREATE TABLE IF NOT EXISTS objectiveai.agent_continuations (
144 agent_instance_hierarchy TEXT PRIMARY KEY NOT NULL,
145 continuation TEXT NOT NULL,
146 updated_at BIGINT NOT NULL
147);
148
149-- Most-recent agent-completion `total_tokens` per AIH. Overwritten
150-- (last-write-wins) by the log writer every time it encounters an
151-- agent-completion chunk carrying a non-NULL usage, at any tier
152-- (standalone agent completion, or nested inside vector/function
153-- executions). A snapshot for sampling token usage over time — not a
154-- running sum.
155CREATE TABLE IF NOT EXISTS objectiveai.agent_token_usage (
156 agent_instance_hierarchy TEXT PRIMARY KEY NOT NULL,
157 total_tokens BIGINT NOT NULL
158);
159
160-- The definition SOURCE per AIH: EITHER the RemotePath the agent's WF
161-- was fetched from, OR the inline WF spec itself — exactly one set.
162-- Blindly upserted (last write wins, no read-before-write) from two
163-- sites: `agents spawn` for spawns by SPEC at AIH-lock acquisition,
164-- and the log writer whenever a chunk carries `agent_inline` (the
165-- first chunk of every completion, any tier/nesting).
166CREATE TABLE IF NOT EXISTS objectiveai.agent_refs (
167 agent_instance_hierarchy TEXT PRIMARY KEY NOT NULL,
168 remote TEXT,
169 inline JSONB,
170 updated_at BIGINT NOT NULL,
171 CHECK (
172 (remote IS NOT NULL AND inline IS NULL)
173 OR
174 (remote IS NULL AND inline IS NOT NULL)
175 )
176);
177
178-- AFTER-INSERT/UPDATE trigger on `agent_token_usage`: every write
179-- emits `NOTIFY agent_token_usage_changed '<agent_instance_hierarchy>'`
180-- so `agents logs token-usage subscribe` wakes the instant an AIH's
181-- token snapshot is written. The listener re-reads and compares to a
182-- baseline, so a same-value overwrite (the writer's ON CONFLICT DO
183-- UPDATE fires this even when the number is unchanged) is filtered
184-- out client-side.
185CREATE OR REPLACE FUNCTION objectiveai.notify_agent_token_usage_changed()
186RETURNS trigger AS $$
187BEGIN
188 PERFORM pg_notify('agent_token_usage_changed', NEW.agent_instance_hierarchy);
189 RETURN NEW;
190END;
191$$ LANGUAGE plpgsql;
192CREATE OR REPLACE TRIGGER agent_token_usage_changed_notify
193AFTER INSERT OR UPDATE ON objectiveai.agent_token_usage
194FOR EACH ROW EXECUTE FUNCTION objectiveai.notify_agent_token_usage_changed();
195
196CREATE TABLE IF NOT EXISTS objectiveai.message_queue (
197 id BIGSERIAL PRIMARY KEY,
198 agent_instance_hierarchy TEXT,
199 agent_tag TEXT,
200 -- AIH of the caller who enqueued this row (sourced from
201 -- `ctx.config.agent_instance_hierarchy` at enqueue time).
202 -- Surfaced on `agents queue read pending` so callers can
203 -- audit "who asked for this" without a join.
204 sender_agent_instance_hierarchy TEXT NOT NULL,
205 -- Content rows live in `message_queue_contents` (PK
206 -- `id BIGSERIAL`, FK `message_queue_id` → here). Readers
207 -- JOIN; no denormalized JSON shadow column lives here.
208 enqueued_at BIGINT NOT NULL,
209 key TEXT,
210 -- Soft-delete marker. Rows start at TRUE and flip to FALSE
211 -- when consumed (either via the LogWriter's MessageQueue row
212 -- write or via `db::message_queue::delete_by_id`'s in-flight
213 -- lock-race-released path). Every reader filters
214 -- `WHERE active = TRUE`, so flipped rows are invisible.
215 -- Content stays around in `message_queue_contents` (the old
216 -- `ON DELETE CASCADE` chain no longer fires because we don't
217 -- DELETE).
218 active BOOLEAN NOT NULL DEFAULT TRUE,
219 CHECK (
220 (agent_instance_hierarchy IS NOT NULL AND agent_tag IS NULL)
221 OR
222 (agent_instance_hierarchy IS NULL AND agent_tag IS NOT NULL)
223 )
224);
225CREATE INDEX IF NOT EXISTS message_queue_hierarchy_idx
226 ON objectiveai.message_queue(agent_instance_hierarchy, id)
227 WHERE agent_instance_hierarchy IS NOT NULL;
228CREATE INDEX IF NOT EXISTS message_queue_tag_idx
229 ON objectiveai.message_queue(agent_tag, id)
230 WHERE agent_tag IS NOT NULL;
231-- Per-target idempotency keys. The `AND active = TRUE` clause
232-- means inactive prior rows don't count toward uniqueness — an
233-- `agents message --enqueue-with-key k` after a prior consumption
234-- inserts cleanly without UNIQUE-violating the soft-flipped row.
235CREATE UNIQUE INDEX IF NOT EXISTS message_queue_key_hierarchy_unique_idx
236 ON objectiveai.message_queue(agent_instance_hierarchy, key)
237 WHERE agent_instance_hierarchy IS NOT NULL
238 AND key IS NOT NULL
239 AND active = TRUE;
240CREATE UNIQUE INDEX IF NOT EXISTS message_queue_key_tag_unique_idx
241 ON objectiveai.message_queue(agent_tag, key)
242 WHERE agent_tag IS NOT NULL
243 AND key IS NOT NULL
244 AND active = TRUE;
245
246CREATE TABLE IF NOT EXISTS objectiveai.message_queue_contents (
247 id BIGSERIAL PRIMARY KEY,
248 message_queue_id BIGINT NOT NULL,
249 kind TEXT NOT NULL
250 CHECK (kind IN ('text','image','audio','video','file')),
251 FOREIGN KEY (message_queue_id) REFERENCES objectiveai.message_queue(id) ON DELETE CASCADE
252);
253CREATE INDEX IF NOT EXISTS message_queue_contents_parent_idx
254 ON objectiveai.message_queue_contents(message_queue_id);
255
256CREATE TABLE IF NOT EXISTS objectiveai.message_queue_texts (
257 id BIGINT PRIMARY KEY,
258 text TEXT NOT NULL,
259 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
260);
261
262CREATE TABLE IF NOT EXISTS objectiveai.message_queue_images (
263 id BIGINT PRIMARY KEY,
264 url TEXT NOT NULL,
265 detail TEXT,
266 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
267);
268
269CREATE TABLE IF NOT EXISTS objectiveai.message_queue_audios (
270 id BIGINT PRIMARY KEY,
271 data TEXT NOT NULL,
272 format TEXT NOT NULL,
273 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
274);
275
276CREATE TABLE IF NOT EXISTS objectiveai.message_queue_videos (
277 id BIGINT PRIMARY KEY,
278 url TEXT NOT NULL,
279 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
280);
281
282CREATE TABLE IF NOT EXISTS objectiveai.message_queue_files (
283 id BIGINT PRIMARY KEY,
284 file_data TEXT,
285 file_id TEXT,
286 filename TEXT,
287 file_url TEXT,
288 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
289);
290
291-- AFTER-UPDATE trigger on `message_queue.active`: every soft-flip
292-- (TRUE → FALSE) emits a `NOTIFY message_queue_inactive '<id>'`
293-- so the cli's `db::message_queue::subscribe_delivered` listener
294-- wakes up the instant a consumption flip lands. Pure native
295-- LISTEN/NOTIFY — no polling. We no longer hard-delete, so the
296-- prior AFTER DELETE trigger is gone.
297CREATE OR REPLACE FUNCTION objectiveai.notify_message_queue_inactive()
298RETURNS trigger AS $$
299BEGIN
300 IF OLD.active = TRUE AND NEW.active = FALSE THEN
301 PERFORM pg_notify('message_queue_inactive', NEW.id::text);
302 END IF;
303 RETURN NEW;
304END;
305$$ LANGUAGE plpgsql;
306CREATE OR REPLACE TRIGGER message_queue_inactive_notify
307AFTER UPDATE OF active ON objectiveai.message_queue
308FOR EACH ROW EXECUTE FUNCTION objectiveai.notify_message_queue_inactive();
309
310"#;
311
312const LOGS_SCHEMA: &str = include_str!("logs/schema.sql");
317
318const READER_GROUP: &str = r#"
328DO $$
329BEGIN
330 CREATE ROLE objectiveai_read NOLOGIN;
331EXCEPTION WHEN duplicate_object THEN
332 NULL;
333END
334$$;
335GRANT USAGE ON SCHEMA objectiveai TO objectiveai_read;
336GRANT SELECT ON ALL TABLES IN SCHEMA objectiveai TO objectiveai_read;
337ALTER DEFAULT PRIVILEGES IN SCHEMA objectiveai GRANT SELECT ON TABLES TO objectiveai_read;
338"#;
339
340pub async fn init(url: &str, database: &str) -> Result<Pool, Error> {
347 let app_url = format!(
348 "{}/{}",
349 url.trim_end_matches('/'),
350 percent_encoding::utf8_percent_encode(database, percent_encoding::NON_ALPHANUMERIC),
351 );
352
353 let admin = PgPoolOptions::new()
357 .max_connections(1)
358 .connect(url)
359 .await
360 .map_err(|e| unreachable_hint(url, e))?;
361 let exists: bool = {
362 let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)")
363 .bind(database)
364 .fetch_one(&admin)
365 .await?;
366 row.try_get::<bool, _>(0)?
367 };
368 if !exists {
369 let quoted = database.replace('"', "\"\"");
379 match admin
380 .execute(format!("CREATE DATABASE \"{quoted}\"").as_str())
381 .await
382 {
383 Ok(_) => {}
384 Err(sqlx::Error::Database(db))
391 if matches!(db.code().as_deref(), Some("42P04") | Some("23505")) => {}
392 Err(e) => return Err(e.into()),
393 }
394 }
395 admin.close().await;
396
397 let pool = PgPoolOptions::new()
410 .max_connections(8)
411 .connect(&app_url)
412 .await
413 .map_err(|e| unreachable_hint(&app_url, e))?;
414 {
415 const SCHEMA_LOCK_KEY: i64 = 0x0B7EC71AE_15CBE_AA_i64;
420 let mut conn = pool.acquire().await?;
421 sqlx::query("SELECT pg_advisory_lock($1)")
422 .bind(SCHEMA_LOCK_KEY)
423 .execute(&mut *conn)
424 .await?;
425 let apply_result: Result<(), Error> = async {
426 conn.execute(SCHEMA).await?;
427 conn.execute(LOGS_SCHEMA).await?;
428 conn.execute(READER_GROUP).await?;
429 Ok(())
430 }
431 .await;
432 let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
435 .bind(SCHEMA_LOCK_KEY)
436 .execute(&mut *conn)
437 .await;
438 apply_result?;
439 }
440
441 Ok(Pool(pool))
442}
443
444pub(crate) fn config_url(address: &str, user: &str, password: &str) -> String {
451 let user =
452 percent_encoding::utf8_percent_encode(user, percent_encoding::NON_ALPHANUMERIC);
453 let password =
454 percent_encoding::utf8_percent_encode(password, percent_encoding::NON_ALPHANUMERIC);
455 format!("postgres://{user}:{password}@{address}")
456}
457
458fn unreachable_hint(url: &str, source: sqlx::Error) -> Error {
465 Error::DbUnreachable {
466 url: redact_url_password(url),
467 source: Box::new(source),
468 }
469}
470
471fn redact_url_password(url: &str) -> String {
476 let Some(scheme_end) = url.find("://") else {
477 return url.to_string();
478 };
479 let rest = &url[scheme_end + 3..];
480 let authority = &rest[..rest.find('/').unwrap_or(rest.len())];
481 let Some(at) = authority.rfind('@') else {
482 return url.to_string();
483 };
484 let userinfo = &authority[..at];
485 let Some(colon) = userinfo.find(':') else {
486 return url.to_string();
487 };
488 format!(
489 "{}:***{}",
490 &url[..scheme_end + 3 + colon],
491 &url[scheme_end + 3 + at..],
492 )
493}
494
495#[cfg(test)]
496mod tests {
497 use super::redact_url_password;
498
499 #[test]
500 fn redact_url_password_cases() {
501 assert_eq!(
502 redact_url_password("postgresql://postgres:s3cr%40t@127.0.0.1:5432"),
503 "postgresql://postgres:***@127.0.0.1:5432",
504 );
505 assert_eq!(
506 redact_url_password("postgres://u:p@h:5432/objectiveai"),
507 "postgres://u:***@h:5432/objectiveai",
508 );
509 assert_eq!(
511 redact_url_password("postgres://postgres@127.0.0.1:5432"),
512 "postgres://postgres@127.0.0.1:5432",
513 );
514 assert_eq!(
516 redact_url_password("postgres://127.0.0.1:5432/db"),
517 "postgres://127.0.0.1:5432/db",
518 );
519 assert_eq!(redact_url_password("127.0.0.1:5432"), "127.0.0.1:5432");
521 }
522}