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-- Latest continuation token per agent_instance_hierarchy. Upserted
83-- per streamed chunk by the chunk-yielder loops in `agents spawn`
84-- and `functions execute`. No GC, no history — querying it gives
85-- the single most recent continuation for that AIH.
86CREATE TABLE IF NOT EXISTS objectiveai.agent_continuations (
87 agent_instance_hierarchy TEXT PRIMARY KEY NOT NULL,
88 continuation TEXT NOT NULL,
89 updated_at BIGINT NOT NULL
90);
91
92CREATE TABLE IF NOT EXISTS objectiveai.message_queue (
93 id BIGSERIAL PRIMARY KEY,
94 agent_instance_hierarchy TEXT,
95 agent_tag TEXT,
96 -- AIH of the caller who enqueued this row (sourced from
97 -- `ctx.config.agent_instance_hierarchy` at enqueue time).
98 -- Surfaced on `agents queue read pending` so callers can
99 -- audit "who asked for this" without a join.
100 sender_agent_instance_hierarchy TEXT NOT NULL,
101 -- Content rows live in `message_queue_contents` (PK
102 -- `id BIGSERIAL`, FK `message_queue_id` → here). Readers
103 -- JOIN; no denormalized JSON shadow column lives here.
104 enqueued_at BIGINT NOT NULL,
105 key TEXT,
106 -- Soft-delete marker. Rows start at TRUE and flip to FALSE
107 -- when consumed (either via the LogWriter's MessageQueue row
108 -- write or via `db::message_queue::delete_by_id`'s in-flight
109 -- lock-race-released path). Every reader filters
110 -- `WHERE active = TRUE`, so flipped rows are invisible.
111 -- Content stays around in `message_queue_contents` (the old
112 -- `ON DELETE CASCADE` chain no longer fires because we don't
113 -- DELETE).
114 active BOOLEAN NOT NULL DEFAULT TRUE,
115 CHECK (
116 (agent_instance_hierarchy IS NOT NULL AND agent_tag IS NULL)
117 OR
118 (agent_instance_hierarchy IS NULL AND agent_tag IS NOT NULL)
119 )
120);
121CREATE INDEX IF NOT EXISTS message_queue_hierarchy_idx
122 ON objectiveai.message_queue(agent_instance_hierarchy, id)
123 WHERE agent_instance_hierarchy IS NOT NULL;
124CREATE INDEX IF NOT EXISTS message_queue_tag_idx
125 ON objectiveai.message_queue(agent_tag, id)
126 WHERE agent_tag IS NOT NULL;
127-- Per-target idempotency keys. The `AND active = TRUE` clause
128-- means inactive prior rows don't count toward uniqueness — an
129-- `agents message --enqueue-with-key k` after a prior consumption
130-- inserts cleanly without UNIQUE-violating the soft-flipped row.
131CREATE UNIQUE INDEX IF NOT EXISTS message_queue_key_hierarchy_unique_idx
132 ON objectiveai.message_queue(agent_instance_hierarchy, key)
133 WHERE agent_instance_hierarchy IS NOT NULL
134 AND key IS NOT NULL
135 AND active = TRUE;
136CREATE UNIQUE INDEX IF NOT EXISTS message_queue_key_tag_unique_idx
137 ON objectiveai.message_queue(agent_tag, key)
138 WHERE agent_tag IS NOT NULL
139 AND key IS NOT NULL
140 AND active = TRUE;
141
142CREATE TABLE IF NOT EXISTS objectiveai.message_queue_contents (
143 id BIGSERIAL PRIMARY KEY,
144 message_queue_id BIGINT NOT NULL,
145 kind TEXT NOT NULL
146 CHECK (kind IN ('text','image','audio','video','file')),
147 FOREIGN KEY (message_queue_id) REFERENCES objectiveai.message_queue(id) ON DELETE CASCADE
148);
149CREATE INDEX IF NOT EXISTS message_queue_contents_parent_idx
150 ON objectiveai.message_queue_contents(message_queue_id);
151
152CREATE TABLE IF NOT EXISTS objectiveai.message_queue_texts (
153 id BIGINT PRIMARY KEY,
154 text TEXT NOT NULL,
155 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
156);
157
158CREATE TABLE IF NOT EXISTS objectiveai.message_queue_images (
159 id BIGINT PRIMARY KEY,
160 url TEXT NOT NULL,
161 detail TEXT,
162 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
163);
164
165CREATE TABLE IF NOT EXISTS objectiveai.message_queue_audios (
166 id BIGINT PRIMARY KEY,
167 data TEXT NOT NULL,
168 format TEXT NOT NULL,
169 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
170);
171
172CREATE TABLE IF NOT EXISTS objectiveai.message_queue_videos (
173 id BIGINT PRIMARY KEY,
174 url TEXT NOT NULL,
175 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
176);
177
178CREATE TABLE IF NOT EXISTS objectiveai.message_queue_files (
179 id BIGINT PRIMARY KEY,
180 file_data TEXT,
181 file_id TEXT,
182 filename TEXT,
183 file_url TEXT,
184 FOREIGN KEY (id) REFERENCES objectiveai.message_queue_contents(id) ON DELETE CASCADE
185);
186
187CREATE TABLE IF NOT EXISTS objectiveai.schedules (
188 id BIGSERIAL PRIMARY KEY,
189 name TEXT NOT NULL,
190 command TEXT NOT NULL,
191 description TEXT NOT NULL,
192 agent_instance_hierarchy TEXT NOT NULL,
193 interval_seconds BIGINT
194 CHECK (interval_seconds IS NULL OR interval_seconds >= 0),
195 agent_arguments TEXT NOT NULL,
196 -- Versions are separate rows: `tasks schedule --overwrite` INSERTS
197 -- a new row with version = max+1 that shadows the older versions
198 -- of the same (name, aih). Shadowed rows never run again and never
199 -- list, but stay on disk so `tasks_runs` history is per-version.
200 -- Rows are NEVER deleted.
201 version BIGINT NOT NULL DEFAULT 1,
202 -- Plugin coordinate of the plugin that registered this schedule,
203 -- captured from `ctx.plugin` at schedule time. All three NULL when
204 -- not scheduled by a plugin; all three set when it was (enforced by
205 -- the CHECK below).
206 plugin_owner TEXT,
207 plugin_repository TEXT,
208 plugin_version TEXT,
209 created_at BIGINT NOT NULL,
210 -- A schedule name is unique per agent instance hierarchy and
211 -- version — two different agents may reuse the same name, and one
212 -- agent's schedule keeps every version it ever had as rows.
213 UNIQUE (name, agent_instance_hierarchy, version),
214 CHECK (
215 (plugin_owner IS NULL AND plugin_repository IS NULL AND plugin_version IS NULL)
216 OR
217 (plugin_owner IS NOT NULL AND plugin_repository IS NOT NULL AND plugin_version IS NOT NULL)
218 )
219);
220
221-- One row per schedule firing. A oneshot is exhausted the moment it
222-- has a run; a recurring row's readiness keys off its newest run.
223-- Written atomically by the same claim query that selects the tasks
224-- `tasks run` fires, so concurrent runners never double-claim.
225CREATE TABLE IF NOT EXISTS objectiveai.tasks_runs (
226 id BIGSERIAL PRIMARY KEY,
227 schedule_id BIGINT NOT NULL REFERENCES objectiveai.schedules(id),
228 ran_at BIGINT NOT NULL
229);
230CREATE INDEX IF NOT EXISTS tasks_runs_schedule_idx
231 ON objectiveai.tasks_runs(schedule_id, ran_at DESC);
232
233-- One row per item a fired task emitted on `tasks run`'s stream,
234-- serialized exactly as the wire envelope, linked to its run.
235CREATE TABLE IF NOT EXISTS objectiveai.tasks_logs (
236 id BIGSERIAL PRIMARY KEY,
237 run_id BIGINT NOT NULL REFERENCES objectiveai.tasks_runs(id),
238 value TEXT NOT NULL,
239 created_at BIGINT NOT NULL
240);
241CREATE INDEX IF NOT EXISTS tasks_logs_run_idx
242 ON objectiveai.tasks_logs(run_id, id);
243
244-- AFTER-UPDATE trigger on `message_queue.active`: every soft-flip
245-- (TRUE → FALSE) emits a `NOTIFY message_queue_inactive '<id>'`
246-- so the cli's `db::message_queue::subscribe_delivered` listener
247-- wakes up the instant a consumption flip lands. Pure native
248-- LISTEN/NOTIFY — no polling. We no longer hard-delete, so the
249-- prior AFTER DELETE trigger is gone.
250CREATE OR REPLACE FUNCTION objectiveai.notify_message_queue_inactive()
251RETURNS trigger AS $$
252BEGIN
253 IF OLD.active = TRUE AND NEW.active = FALSE THEN
254 PERFORM pg_notify('message_queue_inactive', NEW.id::text);
255 END IF;
256 RETURN NEW;
257END;
258$$ LANGUAGE plpgsql;
259CREATE OR REPLACE TRIGGER message_queue_inactive_notify
260AFTER UPDATE OF active ON objectiveai.message_queue
261FOR EACH ROW EXECUTE FUNCTION objectiveai.notify_message_queue_inactive();
262
263"#;
264
265const LOGS_SCHEMA: &str = include_str!("logs/schema.sql");
270
271const READER_GROUP: &str = r#"
281DO $$
282BEGIN
283 CREATE ROLE objectiveai_read NOLOGIN;
284EXCEPTION WHEN duplicate_object THEN
285 NULL;
286END
287$$;
288GRANT USAGE ON SCHEMA objectiveai TO objectiveai_read;
289GRANT SELECT ON ALL TABLES IN SCHEMA objectiveai TO objectiveai_read;
290ALTER DEFAULT PRIVILEGES IN SCHEMA objectiveai GRANT SELECT ON TABLES TO objectiveai_read;
291"#;
292
293pub async fn init(url: &str, database: &str) -> Result<Pool, Error> {
300 let app_url = format!(
301 "{}/{}",
302 url.trim_end_matches('/'),
303 percent_encoding::utf8_percent_encode(database, percent_encoding::NON_ALPHANUMERIC),
304 );
305
306 let admin = PgPoolOptions::new()
310 .max_connections(1)
311 .connect(url)
312 .await
313 .map_err(|e| unreachable_hint(url, e))?;
314 let exists: bool = {
315 let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)")
316 .bind(database)
317 .fetch_one(&admin)
318 .await?;
319 row.try_get::<bool, _>(0)?
320 };
321 if !exists {
322 let quoted = database.replace('"', "\"\"");
332 match admin
333 .execute(format!("CREATE DATABASE \"{quoted}\"").as_str())
334 .await
335 {
336 Ok(_) => {}
337 Err(sqlx::Error::Database(db))
344 if matches!(db.code().as_deref(), Some("42P04") | Some("23505")) => {}
345 Err(e) => return Err(e.into()),
346 }
347 }
348 admin.close().await;
349
350 let pool = PgPoolOptions::new()
363 .max_connections(8)
364 .connect(&app_url)
365 .await
366 .map_err(|e| unreachable_hint(&app_url, e))?;
367 {
368 const SCHEMA_LOCK_KEY: i64 = 0x0B7EC71AE_15CBE_AA_i64;
373 let mut conn = pool.acquire().await?;
374 sqlx::query("SELECT pg_advisory_lock($1)")
375 .bind(SCHEMA_LOCK_KEY)
376 .execute(&mut *conn)
377 .await?;
378 let apply_result: Result<(), Error> = async {
379 conn.execute(SCHEMA).await?;
380 conn.execute(LOGS_SCHEMA).await?;
381 conn.execute(READER_GROUP).await?;
382 Ok(())
383 }
384 .await;
385 let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
388 .bind(SCHEMA_LOCK_KEY)
389 .execute(&mut *conn)
390 .await;
391 apply_result?;
392 }
393
394 Ok(Pool(pool))
395}
396
397pub(crate) fn config_url(address: &str, user: &str, password: &str) -> String {
404 let user =
405 percent_encoding::utf8_percent_encode(user, percent_encoding::NON_ALPHANUMERIC);
406 let password =
407 percent_encoding::utf8_percent_encode(password, percent_encoding::NON_ALPHANUMERIC);
408 format!("postgres://{user}:{password}@{address}")
409}
410
411fn unreachable_hint(url: &str, source: sqlx::Error) -> Error {
418 Error::DbUnreachable {
419 url: redact_url_password(url),
420 source: Box::new(source),
421 }
422}
423
424fn redact_url_password(url: &str) -> String {
429 let Some(scheme_end) = url.find("://") else {
430 return url.to_string();
431 };
432 let rest = &url[scheme_end + 3..];
433 let authority = &rest[..rest.find('/').unwrap_or(rest.len())];
434 let Some(at) = authority.rfind('@') else {
435 return url.to_string();
436 };
437 let userinfo = &authority[..at];
438 let Some(colon) = userinfo.find(':') else {
439 return url.to_string();
440 };
441 format!(
442 "{}:***{}",
443 &url[..scheme_end + 3 + colon],
444 &url[scheme_end + 3 + at..],
445 )
446}
447
448#[cfg(test)]
449mod tests {
450 use super::redact_url_password;
451
452 #[test]
453 fn redact_url_password_cases() {
454 assert_eq!(
455 redact_url_password("postgresql://postgres:s3cr%40t@127.0.0.1:5432"),
456 "postgresql://postgres:***@127.0.0.1:5432",
457 );
458 assert_eq!(
459 redact_url_password("postgres://u:p@h:5432/objectiveai"),
460 "postgres://u:***@h:5432/objectiveai",
461 );
462 assert_eq!(
464 redact_url_password("postgres://postgres@127.0.0.1:5432"),
465 "postgres://postgres@127.0.0.1:5432",
466 );
467 assert_eq!(
469 redact_url_password("postgres://127.0.0.1:5432/db"),
470 "postgres://127.0.0.1:5432/db",
471 );
472 assert_eq!(redact_url_password("127.0.0.1:5432"), "127.0.0.1:5432");
474 }
475}