diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 20f676e9a85..81a16cf0c5e 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -703,8 +703,9 @@ mod postgres_tests { migrations.sort_by_key(|migration| migration.version); // upstream carries 44 (0032-0034 and 0040 adopted from our PRs); - // fork adds 0046_task_system (PR #6425 pending upstream). - assert_eq!(migrations.len(), 45); + // fork adds 0046_task_system (PR #6425 pending upstream) and + // 0047_agent_machine_homes (AGENT-HOMES-001 PR-3). + assert_eq!(migrations.len(), 46); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 759e916e4b4..f65ba501ee8 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -360,6 +360,129 @@ async fn set_agent_owner_with_operation( Ok(true) } +/// The machine an agent calls home: stable host id, human label, and runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MachineHome { + /// Stable host identity (the desktop's device id). + pub machine_id: String, + /// Human-facing, renameable label. + pub machine_label: Option, + /// Runtime serving this home (`"hermes"`, `"openclaw"`, `"claude-code"`, ...). + pub machine_runtime: Option, +} + +/// Register (or re-register) `agent_pubkey` as the home agent for a machine. +/// +/// One home per machine per community is a database invariant +/// (`idx_users_one_home_per_machine`), not a check performed here: a +/// read-then-write would race two concurrent registrations onto the same host. +/// A conflicting claim therefore surfaces as a unique violation, which is +/// translated to [`DbError::AccessDenied`] so callers get an actionable +/// message instead of a raw SQLSTATE. +/// +/// Returns `Err(DbError::NotFound)` if the agent pubkey has no `users` row. +pub async fn set_machine_home( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], + home: &MachineHome, +) -> Result<()> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let result = sqlx::query( + r#"UPDATE users SET machine_id = $1, machine_label = $2, machine_runtime = $3, updated_at = NOW() WHERE community_id = $4 AND pubkey = $5"#, + ) + .bind(&home.machine_id) + .bind(home.machine_label.as_deref()) + .bind(home.machine_runtime.as_deref()) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .execute(&mut *connection) + .await; + + match result { + Ok(done) if done.rows_affected() == 0 => Err(crate::error::DbError::NotFound( + "agent pubkey not found in users table".into(), + )), + Ok(_) => Ok(()), + // 23505 = unique_violation: another agent already homes this machine. + Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => { + Err(crate::error::DbError::AccessDenied(format!( + "machine {} already has a home agent in this community", + home.machine_id + ))) + } + Err(e) => Err(e.into()), + } +} + +/// Clear an agent's machine home, freeing the machine for another agent. +/// +/// Returns `true` if a home was cleared, `false` if the row exists but had no +/// home. All three columns drop together: the migration's +/// `chk_users_machine_fields_require_machine_id` makes a label or runtime +/// without a `machine_id` unrepresentable. +pub async fn clear_machine_home( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], +) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let result = sqlx::query( + r#"UPDATE users SET machine_id = NULL, machine_label = NULL, machine_runtime = NULL, updated_at = NOW() WHERE community_id = $1 AND pubkey = $2 AND machine_id IS NOT NULL"#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .execute(&mut *connection) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Look up an agent's machine home. `None` when the user is absent or unhomed. +pub async fn get_machine_home( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#"SELECT machine_id, machine_label, machine_runtime FROM users WHERE community_id = $1 AND pubkey = $2 AND machine_id IS NOT NULL"#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| MachineHome { + machine_id: r.get("machine_id"), + machine_label: r.get("machine_label"), + machine_runtime: r.get("machine_runtime"), + })) +} + +/// Resolve the agent that homes `machine_id`, if any. +/// +/// This is the lookup that makes a machine home addressable: given a host, find +/// the pubkey that answers for it. +pub async fn get_agent_for_machine( + pool: &PgPool, + community_id: CommunityId, + machine_id: &str, +) -> Result>> { + let row = + sqlx::query(r#"SELECT pubkey FROM users WHERE community_id = $1 AND machine_id = $2"#) + .bind(community_id.as_uuid()) + .bind(machine_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.get::, _>("pubkey"))) +} + /// Get the channel_add_policy and agent_owner_pubkey for a user. /// Returns None if the pubkey is not in the users table. /// Returns Some((policy_str, owner_bytes_or_none)) if found. @@ -861,4 +984,196 @@ mod postgres_tests { assert_eq!(result.0, "owner_only"); assert!(result.1.is_none(), "owner should be None when never set"); } + + /// A registered machine home round-trips, and the machine resolves back to + /// the agent that homes it. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_machine_home_round_trip() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent = random_pubkey(); + ensure_user(&db.pool, community, &agent) + .await + .expect("ensure agent"); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: Some("Winnie".to_owned()), + machine_runtime: Some("openclaw".to_owned()), + }; + set_machine_home(&db.pool, community, &agent, &home) + .await + .expect("set home"); + + let got = get_machine_home(&db.pool, community, &agent) + .await + .expect("get home") + .expect("home should be Some"); + assert_eq!(got, home); + + let resolved = get_agent_for_machine(&db.pool, community, &home.machine_id) + .await + .expect("resolve machine") + .expect("machine should resolve"); + assert_eq!(resolved, agent, "machine must resolve to its home agent"); + } + + /// The core PR-3 invariant: one home agent per machine, per community. The + /// second claim must be rejected rather than silently stealing the host. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_second_agent_cannot_claim_same_machine() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let first = random_pubkey(); + let second = random_pubkey(); + ensure_user(&db.pool, community, &first).await.expect("a"); + ensure_user(&db.pool, community, &second).await.expect("b"); + + let machine_id = format!("machine-{}", uuid::Uuid::new_v4()); + let home = MachineHome { + machine_id: machine_id.clone(), + machine_label: None, + machine_runtime: None, + }; + set_machine_home(&db.pool, community, &first, &home) + .await + .expect("first claim wins"); + + let conflict = set_machine_home(&db.pool, community, &second, &home).await; + assert!( + matches!(conflict, Err(crate::error::DbError::AccessDenied(_))), + "second claim on the same machine must be denied, got {conflict:?}" + ); + + // The original home is untouched by the failed claim. + let still = get_agent_for_machine(&db.pool, community, &machine_id) + .await + .expect("resolve") + .expect("still homed"); + assert_eq!(still, first); + } + + /// Admission confinement: the same machine id in a different community is a + /// different machine, so the unique index must not collide across tenants. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_same_machine_id_allowed_in_other_community() { + let db = setup_db().await; + let community_a = make_community(&db.pool).await; + let community_b = make_community(&db.pool).await; + let agent_a = random_pubkey(); + let agent_b = random_pubkey(); + ensure_user(&db.pool, community_a, &agent_a) + .await + .expect("a"); + ensure_user(&db.pool, community_b, &agent_b) + .await + .expect("b"); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: None, + machine_runtime: None, + }; + set_machine_home(&db.pool, community_a, &agent_a, &home) + .await + .expect("community A claim"); + set_machine_home(&db.pool, community_b, &agent_b, &home) + .await + .expect("community B must be independent of A"); + } + + /// Clearing a home frees the machine for another agent to claim. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_clear_machine_home_frees_the_machine() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let first = random_pubkey(); + let second = random_pubkey(); + ensure_user(&db.pool, community, &first).await.expect("a"); + ensure_user(&db.pool, community, &second).await.expect("b"); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: None, + machine_runtime: None, + }; + set_machine_home(&db.pool, community, &first, &home) + .await + .expect("first claim"); + + assert!( + clear_machine_home(&db.pool, community, &first) + .await + .expect("clear"), + "clearing an existing home reports true" + ); + assert!( + get_machine_home(&db.pool, community, &first) + .await + .expect("get") + .is_none(), + "home is gone after clear" + ); + assert!( + !clear_machine_home(&db.pool, community, &first) + .await + .expect("clear again"), + "clearing an unhomed agent reports false" + ); + + set_machine_home(&db.pool, community, &second, &home) + .await + .expect("machine is free for the next agent"); + } + + /// Registering a home for a pubkey with no users row is an error, not a + /// silent no-op that would leave the machine unhomed. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_set_machine_home_nonexistent_agent() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let ghost = random_pubkey(); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: None, + machine_runtime: None, + }; + let result = set_machine_home(&db.pool, community, &ghost, &home).await; + assert!( + matches!(result, Err(crate::error::DbError::NotFound(_))), + "unknown agent must not be homed, got {result:?}" + ); + } + + /// A label or runtime with no machine_id is unaddressable, and the database + /// must reject it rather than storing a half-registered home. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_label_without_machine_id_is_rejected() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent = random_pubkey(); + ensure_user(&db.pool, community, &agent) + .await + .expect("ensure agent"); + + let result = sqlx::query( + "UPDATE users SET machine_label = $1 WHERE community_id = $2 AND pubkey = $3", + ) + .bind("orphan-label") + .bind(community.as_uuid()) + .bind(&agent) + .execute(&db.pool) + .await; + assert!( + result.is_err(), + "a label with no machine_id must violate chk_users_machine_fields_require_machine_id" + ); + } } diff --git a/migrations/0047_agent_machine_homes.sql b/migrations/0047_agent_machine_homes.sql new file mode 100644 index 00000000000..f2abcbce413 --- /dev/null +++ b/migrations/0047_agent_machine_homes.sql @@ -0,0 +1,59 @@ +-- Machine homes: the machine an agent actually runs on, as first-class relay data. +-- +-- Modeling choice (the open item PR-3 was asked to settle at review): these are +-- columns on `users`, NOT a new `agent_machine_homes` table. 0046 states the rule +-- this follows -- "a task creator/assignee is a `users` row, never a separate +-- agent table. Agents in Buzz *are* users" -- and warns that a dedicated id +-- "would invent a second identity space that nothing else in the schema uses." +-- A side table keyed by `(community_id, pubkey)` would hold exactly one row per +-- agent and would be joined on every read, so it buys no cardinality the user +-- row cannot express, while adding precisely the second identity space 0046 +-- rejects. `users.agent_owner_pubkey` (NIP-OA) already set this precedent: +-- agent-shaped facts live on the agent's own user row. +-- +-- `machine_id` is the stable host identity (the desktop's device id); the label +-- is human-facing and renameable. `machine_runtime` is unconstrained TEXT for +-- the reason 0031 gives for `workflow_runs.error_code` and 0046 repeats for +-- `tasks.source`: a new runtime (openclaw, hermes, claude-code, codex) must be +-- addable across a rolling upgrade without a schema migration. +-- +-- Every constraint leads with `community_id`, as the migration lint +-- (`scoped_primary_key_unique_and_foreign_key_constraints_lead_with_community_id`) +-- requires, so one community's machine registration is invisible to another. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE users + ADD COLUMN machine_id VARCHAR(255), + ADD COLUMN machine_label VARCHAR(255), + ADD COLUMN machine_runtime TEXT; + +-- One home agent per machine, per community. This is the "one-home-per-machine" +-- invariant the agent-homes program is built on: two agents claiming the same +-- host is the exact ambiguity that makes a task assignee meaningless. Enforced +-- as a partial unique index so the (overwhelming) majority of users, who carry +-- no machine_id at all, are entirely unconstrained. +CREATE UNIQUE INDEX idx_users_one_home_per_machine + ON users (community_id, machine_id) + WHERE machine_id IS NOT NULL; + +-- A machine home is meaningless without the machine it names, and a bare label +-- or runtime with no `machine_id` is unaddressable -- it could never be resolved +-- to a host. Rejecting that at the database keeps a half-registered home +-- unrepresentable rather than merely discouraged. +ALTER TABLE users + ADD CONSTRAINT chk_users_machine_fields_require_machine_id + CHECK (machine_id IS NOT NULL + OR (machine_label IS NULL AND machine_runtime IS NULL)); + +-- Blank/whitespace ids and labels are the other way a home becomes +-- unaddressable, and TEXT columns accept them silently. +ALTER TABLE users + ADD CONSTRAINT chk_users_machine_id_not_blank + CHECK (machine_id IS NULL OR length(btrim(machine_id)) > 0), + ADD CONSTRAINT chk_users_machine_label_not_blank + CHECK (machine_label IS NULL OR length(btrim(machine_label)) > 0), + ADD CONSTRAINT chk_users_machine_runtime_not_blank + CHECK (machine_runtime IS NULL OR length(btrim(machine_runtime)) > 0); + +-- `users` already carries the universal community write fence from 0001; adding +-- columns does not detach it, so no re-attach is needed here. diff --git a/schema/schema.sql b/schema/schema.sql index a741e1cfb6d..2a27512f5c0 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -180,8 +180,29 @@ CREATE TABLE users ( metadata_event_id BYTEA, agent_owner_pubkey BYTEA, channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone', + -- Machine home (0047): the machine an agent actually runs on. Columns on + -- `users`, not a side table -- agents in Buzz *are* users (0046), and + -- `agent_owner_pubkey` already set the precedent that agent-shaped facts + -- live on the agent's own row. `machine_id` is the stable host identity; + -- `machine_runtime` is unconstrained TEXT so a new runtime is addable + -- across a rolling upgrade without a schema migration. + machine_id VARCHAR(255), + machine_label VARCHAR(255), + machine_runtime TEXT, PRIMARY KEY (community_id, pubkey), CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32), + -- A machine home is meaningless without the machine it names: a bare label + -- or runtime with no `machine_id` is unaddressable. + CONSTRAINT chk_users_machine_fields_require_machine_id + CHECK (machine_id IS NOT NULL + OR (machine_label IS NULL AND machine_runtime IS NULL)), + -- Blank/whitespace values are the other way a home becomes unaddressable. + CONSTRAINT chk_users_machine_id_not_blank + CHECK (machine_id IS NULL OR length(btrim(machine_id)) > 0), + CONSTRAINT chk_users_machine_label_not_blank + CHECK (machine_label IS NULL OR length(btrim(machine_label)) > 0), + CONSTRAINT chk_users_machine_runtime_not_blank + CHECK (machine_runtime IS NULL OR length(btrim(machine_runtime)) > 0), -- agent owner is a user in the SAME community. FOREIGN KEY (community_id, agent_owner_pubkey) REFERENCES users (community_id, pubkey) ON DELETE SET NULL @@ -193,6 +214,14 @@ CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle)) CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) WHERE okta_user_id IS NOT NULL; +-- One home agent per machine, per community (0047). Two agents claiming the +-- same host is the exact ambiguity that makes a task assignee meaningless. +-- Partial, so the majority of users -- who carry no machine_id -- are +-- entirely unconstrained. +CREATE UNIQUE INDEX idx_users_one_home_per_machine + ON users (community_id, machine_id) + WHERE machine_id IS NOT NULL; + -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the diff --git a/scripts/reconcile-schema-after-pgschema.sql b/scripts/reconcile-schema-after-pgschema.sql index 7c3a7be871a..420159ac290 100644 --- a/scripts/reconcile-schema-after-pgschema.sql +++ b/scripts/reconcile-schema-after-pgschema.sql @@ -205,6 +205,47 @@ ALTER TABLE replica_heartbeat SET (vacuum_truncate = false); INSERT INTO replica_heartbeat (id) VALUES (1) ON CONFLICT (id) DO NOTHING; +-- pgschema does not reproduce multi-column CHECK constraints, so it drops +-- chk_users_machine_fields_require_machine_id (it keeps the three +-- single-column machine CHECKs and the partial unique index). Without this, +-- a pgschema-bootstrapped database accepts a machine_label/machine_runtime +-- with no machine_id -- an unaddressable agent home -- while a +-- migration-managed database rejects it. +ALTER TABLE users DROP CONSTRAINT IF EXISTS chk_users_machine_fields_require_machine_id; +ALTER TABLE users + ADD CONSTRAINT chk_users_machine_fields_require_machine_id + CHECK (machine_id IS NOT NULL + OR (machine_label IS NULL AND machine_runtime IS NULL)); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'users'::regclass + AND conname = 'chk_users_machine_fields_require_machine_id' + ) THEN + RAISE EXCEPTION 'users must enforce machine-home field coherence after pgschema apply'; + END IF; +END $$; + +-- Same pgschema multi-column CHECK gap on the task system (0046): a task could +-- be marked done with no done_at, or carry a done_at while not done. +ALTER TABLE tasks DROP CONSTRAINT IF EXISTS chk_tasks_done_at_matches_status; +ALTER TABLE tasks + ADD CONSTRAINT chk_tasks_done_at_matches_status + CHECK ((status = 'done') = (done_at IS NOT NULL)); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'tasks'::regclass + AND conname = 'chk_tasks_done_at_matches_status' + ) THEN + RAISE EXCEPTION 'tasks must enforce done_at/status coherence after pgschema apply'; + END IF; +END $$; + DO $$ BEGIN IF NOT EXISTS (