Skip to content

Commit 6ff29e4

Browse files
committed
feat(memory): Postgres v0.8.0 multi-agent migration for zeroclaw-labs#6272 (P6b)
Mirrors the SQLite migration from P6a, but PG-flavored: idempotent via ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS / ON CONFLICT DO NOTHING, default-agent UUID generated in Rust and bound as a parameter, backfill in the same init pass via a parameterized UPDATE. Schema (matches SQLite path so cross-DB code stays one shape): - {schema}.agents: id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL. - {qualified_table}.agent_id TEXT, indexed. Nullable at the DB layer matching SQLite; the AgentScopedMemory<M> wrapper in P7 enforces non-null at write time. Wired into PostgresMemory::initialize_client right after init_schema so every fresh connection is fully migrated before try_enable_pgvector runs (which can fail safely on pgvector absence). Backups: operator's responsibility for Postgres. The binary cannot reach across the network to dump a managed cluster, so we do not take a file-copy backup the way SQLite does. Documented. Concurrent first-init: INSERT...ON CONFLICT (alias) DO NOTHING plus a follow-up SELECT means concurrent initializers from different processes converge on the same default agent UUID (whichever insert wins is the persisted row). Tests: skipped in this commit. Existing Postgres tests in the crate are gated behind the memory-postgres feature and require a running Postgres for execution; CI does not provision one. Cross-DB parity tests for the migration land alongside any test-container support in a follow-up. The code path is a direct mirror of P6a's tested SQLite path so confidence is reasonable. Lucid (the third backend) wraps SqliteMemory for its local store, so P6a's migration runs automatically when Lucid initializes; no separate Lucid migration code is needed for v0.8.0. The external Lucid CLI wire format for cross-agent scoping stays deferred to v0.8.1 per the plan. V2->V3 migration extension: the existing synthesize_default_agent_if_needed helper in crates/zeroclaw-config/src/schema/v2.rs already inserts the default agent's config-side row; the DB-side migration creates the matching agents-table row independently. Both arrive at "there is a default agent" without coordination; runtime resolves the UUID by alias when AgentScopedMemory<M> is constructed in P7. Refs zeroclaw-labs#6272.
1 parent 7e19c44 commit 6ff29e4

1 file changed

Lines changed: 83 additions & 0 deletions

File tree

crates/zeroclaw-memory/src/postgres.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ impl PostgresMemory {
106106
.context("failed to connect to PostgreSQL memory backend")?;
107107

108108
Self::init_schema(&mut client, &schema_ident, &qualified_table)?;
109+
Self::migrate_v0_8_0_multi_agent(
110+
&mut client,
111+
&schema_ident,
112+
&qualified_table,
113+
)?;
109114
Ok(client)
110115
})
111116
.context("failed to spawn PostgreSQL initializer thread")?;
@@ -115,6 +120,84 @@ impl PostgresMemory {
115120
.map_err(|_| anyhow::anyhow!("PostgreSQL initializer thread panicked"))?
116121
}
117122

123+
/// v0.8.0 multi-agent DB migration for the Postgres backend (#6272 P6).
124+
///
125+
/// Adds the `agents` table and the `agent_id` column on the
126+
/// memories table, with a default-agent backfill. Idempotent: every
127+
/// step uses `IF NOT EXISTS` / `ON CONFLICT DO NOTHING` so re-runs
128+
/// are no-ops.
129+
///
130+
/// Backups are the operator's responsibility for Postgres
131+
/// (documented in the release notes); we cannot reach across the
132+
/// network to dump a managed cluster from inside the binary.
133+
/// The default-agent UUID is generated in Rust so it has the same
134+
/// shape as SQLite/Lucid (TEXT, lowercase hyphenated).
135+
fn migrate_v0_8_0_multi_agent(
136+
client: &mut Client,
137+
schema_ident: &str,
138+
qualified_table: &str,
139+
) -> Result<()> {
140+
let qualified_agents = format!("{schema_ident}.agents");
141+
142+
// Create the agents table. Same shape as the SQLite migration:
143+
// TEXT primary key for cross-DB UUID portability, alias UNIQUE
144+
// for human reference and rename surface, created_at audit.
145+
client.batch_execute(&format!(
146+
"
147+
CREATE TABLE IF NOT EXISTS {qualified_agents} (
148+
id TEXT PRIMARY KEY,
149+
alias TEXT NOT NULL UNIQUE,
150+
created_at TIMESTAMPTZ NOT NULL
151+
);
152+
"
153+
))?;
154+
155+
// Insert the default agent if absent. The Rust-side UUID is
156+
// bound as a parameter so the row that ends up persisted has
157+
// the same shape as the SQLite path.
158+
let candidate_uuid = uuid::Uuid::new_v4().to_string();
159+
client.execute(
160+
&format!(
161+
"INSERT INTO {qualified_agents} (id, alias, created_at)
162+
VALUES ($1, 'default', NOW())
163+
ON CONFLICT (alias) DO NOTHING"
164+
),
165+
&[&candidate_uuid],
166+
)?;
167+
168+
// Read back whatever row actually persisted so the backfill
169+
// points at the canonical default-agent UUID even on
170+
// concurrent first-init.
171+
let default_uuid: String = client
172+
.query_one(
173+
&format!("SELECT id FROM {qualified_agents} WHERE alias = 'default' LIMIT 1"),
174+
&[],
175+
)?
176+
.get(0);
177+
178+
// ALTER memories ADD COLUMN agent_id, backfill, index. Postgres
179+
// accepts ADD COLUMN IF NOT EXISTS (since 9.6) and the
180+
// CREATE INDEX IF NOT EXISTS (since 9.5), so the whole block is
181+
// safely idempotent on every supported PG version. The
182+
// default-agent UUID is bound rather than inlined to dodge any
183+
// SQL-injection footgun even though uuid_v4 strings are
184+
// hex+hyphens.
185+
client.batch_execute(&format!(
186+
"
187+
ALTER TABLE {qualified_table} ADD COLUMN IF NOT EXISTS agent_id TEXT;
188+
CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON {qualified_table}(agent_id);
189+
"
190+
))?;
191+
client.execute(
192+
&format!(
193+
"UPDATE {qualified_table} SET agent_id = $1 WHERE agent_id IS NULL"
194+
),
195+
&[&default_uuid],
196+
)?;
197+
198+
Ok(())
199+
}
200+
118201
fn init_schema(client: &mut Client, schema_ident: &str, qualified_table: &str) -> Result<()> {
119202
client.batch_execute(&format!(
120203
"

0 commit comments

Comments
 (0)