Skip to content
Merged
5 changes: 3 additions & 2 deletions crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
315 changes: 315 additions & 0 deletions crates/buzz-db/src/store/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Runtime serving this home (`"hermes"`, `"openclaw"`, `"claude-code"`, ...).
pub machine_runtime: Option<String>,
}

/// 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<bool> {
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<Option<MachineHome>> {
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<Option<Vec<u8>>> {
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::<Vec<u8>, _>("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.
Expand Down Expand Up @@ -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"
);
}
}
59 changes: 59 additions & 0 deletions migrations/0047_agent_machine_homes.sql
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading