mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
fix(desktop): exclude archived agents from nest, order regeneration (#5905)
The managed "Active Agents" table in `~/.buzz/AGENTS.md` was rendered from every managed-agent record with no filtering, so archived duplicate instances kept appearing under the active relay's header. This scopes the roster to identity-active agents and makes regeneration safe under concurrency. ## Roster filter: identity-archive only `render_dynamic_section` now drops only records whose pubkey is present in the relay's `kind:13535` archive snapshot. Local records can't tell they're archived — they all carry `is_active: true` (that flag is a *definition*-archive, not an identity-archive), so archive truth lives only relay-side. The read **fails open**: an unreachable relay yields an empty set and hides no one. There is deliberately **no** relay-scope filter. `relay_url` is a legacy creation-era field that `effective_agent_relay_url()` ignores — every agent is eligible on every community, and snapshot-imported records store `relay_url: ""` by design. Filtering on it would hide valid, runnable agents after a workspace switch or import. Foreign-relay relic records leave the table via record deletion, not code. ## Regeneration on archive / unarchive `archive_identity` and `unarchive_identity` submitted the relay event and returned without refreshing `AGENTS.md`, unlike the ~20 other mutation sites that call `try_regenerate_nest`. A just-archived agent therefore lingered on the roster until an unrelated edit or the next launch. Both commands now trigger a regeneration after a successful submit. Regeneration is bound through a `NestRegenTrigger` trait rather than constructing the `try_regenerate_nest` callback at the Tauri-command delegation site. The command cores take `regen: &impl NestRegenTrigger` and own the `|| regen.trigger()` binding; the thin wrappers only pass `&app` (whose `impl` calls `try_regenerate_nest`). This puts the regen wiring inside the unit-tested core — a `CountingRegen` double proves each core fires exactly one regeneration — instead of an untestable seam where a wrapper could silently lose the refresh while the suite stayed green. The regen races the relay's `kind:13535` snapshot update, so it's best-effort and fail-open — a stale render self-heals on the next cycle. ## Ordered regeneration `try_regenerate_nest` previously spawned unconstrained tasks that each snapshotted state, awaited two relay requests, then wrote — so a slow pre-edit generation could overwrite a newer one. Boot made this deterministic: the boot regen races the `apply_workspace` regen, and the fallback-relay render could finish last. `NestRegenGate` fixes this with a single `highest_requested` watermark. A monotonic generation is claimed *synchronously* at request time (encoding call order) and advances the watermark under one lock; the spawned task carries its generation and, at commit, reads the watermark under that same lock — the compare-and-write is atomic with no await held across it. A generation whose number is below the current watermark drops its result instead of rolling the file back. Gating on highest-*requested* rather than highest-*written* is the load-bearing choice: if a newer generation is requested but then fails its relay reads, an older in-flight generation must **not** publish its now-obsolete roster. Behavior delta: once a newer regeneration has been requested, no older generation will ever write; if that newer generation fails, the file is left as-is and self-heals on the next trigger rather than regressing to a stale snapshot. This is an ordered, latest-request-wins gate — not a work coalescer: superseded generations still perform their relay reads and drop the result at commit time. The gate's commit lock is acquired with a poison-to-`io::Error` mapping rather than `expect()`, so a poisoned lock degrades to the same warn-and-continue path as any other commit failure instead of panicking the desktop process (a best-effort housekeeping write must never take down the app). ## One relay target per regeneration A regeneration read the workspace relay override three times — the NIP-11 signer in `fetch_relay_self`, the snapshot query in `query_relay`, and the rendered footer — so a workspace switch mid-flight could pair one relay's advertised signer with another relay's snapshot, fail open, and render archived agents as active. `capture_relay_target` now resolves the effective relay (WebSocket + HTTP API base) once, before any network work, and `fetch_archived_pubkeys_at` threads that single target through both the NIP-11 fetch and the `/query`; the footer renders the same target. Signer, snapshot, and footer always belong to one relay. ## Monotonic archival snapshot publishing `publish_nipia_archival_list` stamped its `kind:13535` snapshot with a whole-second `created_at`. A rapid archive→unarchive within the same second produced two events whose NIP-16 replaceable-event tie-break (higher event id wins) could strand the older, stale archive state as canonical. Publishing now uses a bounded retry that re-reads current archive state and rebuilds the snapshot each attempt, so the published list reflects the latest intended state rather than a fixed same-second race loser. After the attempt budget (8) is exhausted the publisher `bail!`s; the sole caller treats that as a `warn!` side effect and continues, matching the surrounding best-effort submit path. ## Test-file split The renderer, `upsert_managed_section`, and the regeneration-gate tests moved to `nest/render_tests.rs` so each test file stays under the repository's 1000-line ratchet. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -512,6 +512,196 @@ mod tests {
|
||||
TenantContext::resolved(CommunityId::from_uuid(id), host)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archival_snapshot_advances_timestamp_for_rapid_state_replacement() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(state) = test_state(pool.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let tenant = seed_test_community(&pool).await;
|
||||
let target_hex = Keys::generate().public_key().to_hex();
|
||||
let request_id = "a".repeat(64);
|
||||
|
||||
state
|
||||
.db
|
||||
.archive(
|
||||
tenant.community(),
|
||||
&target_hex,
|
||||
"self",
|
||||
&target_hex,
|
||||
None,
|
||||
None,
|
||||
&request_id,
|
||||
)
|
||||
.await
|
||||
.expect("archive identity");
|
||||
publish_nipia_archival_list(&tenant, &state)
|
||||
.await
|
||||
.expect("publish archived snapshot");
|
||||
let archived_snapshot = state
|
||||
.db
|
||||
.query_events(&EventQuery {
|
||||
kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]),
|
||||
pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()),
|
||||
global_only: true,
|
||||
limit: Some(1),
|
||||
..EventQuery::for_community(tenant.community())
|
||||
})
|
||||
.await
|
||||
.expect("query archived snapshot")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("archived snapshot exists");
|
||||
|
||||
state
|
||||
.db
|
||||
.unarchive(tenant.community(), &target_hex)
|
||||
.await
|
||||
.expect("unarchive identity");
|
||||
publish_nipia_archival_list(&tenant, &state)
|
||||
.await
|
||||
.expect("publish unarchived snapshot");
|
||||
let final_snapshot = state
|
||||
.db
|
||||
.query_events(&EventQuery {
|
||||
kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]),
|
||||
pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()),
|
||||
global_only: true,
|
||||
limit: Some(1),
|
||||
..EventQuery::for_community(tenant.community())
|
||||
})
|
||||
.await
|
||||
.expect("query final snapshot")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("final snapshot exists");
|
||||
|
||||
assert!(
|
||||
final_snapshot.event.created_at > archived_snapshot.event.created_at,
|
||||
"replacement snapshots must not rely on random same-second event-id ordering"
|
||||
);
|
||||
assert!(
|
||||
!final_snapshot.event.tags.iter().any(|tag| {
|
||||
let fields = tag.as_slice();
|
||||
fields.first().map(String::as_str) == Some("p")
|
||||
&& fields.get(1).map(String::as_str) == Some(target_hex.as_str())
|
||||
}),
|
||||
"final snapshot must reflect the canonical empty archive set"
|
||||
);
|
||||
}
|
||||
|
||||
/// Carl review 4954871389 test (b): a stale (pre-unarchive) publisher whose
|
||||
/// canonical read predates the unarchive must not strand `target` in the
|
||||
/// authoritative 13535. Deterministic via the `publish_test_hooks` barrier:
|
||||
/// the stale publisher is held right after it reads `{target}`; the
|
||||
/// unarchive and the compliant `{}` publish then run; only then is the stale
|
||||
/// publisher released to attempt its write. Its post-insert
|
||||
/// `snapshot_is_current` drift check sees canonical `{}` ≠ its `{target}`
|
||||
/// snapshot, so it rebuilds and converges. RED-on-revert: replace that guard
|
||||
/// with `let snapshot_is_current = true;` and the released stale publisher
|
||||
/// commits `{target}` last, stranding the unarchived identity.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_archival_publishers_converge_on_canonical_state() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(state) = test_state(pool.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let tenant = seed_test_community(&pool).await;
|
||||
let target_hex = Keys::generate().public_key().to_hex();
|
||||
let request_id = "b".repeat(64);
|
||||
|
||||
// canonical -> {target}
|
||||
state
|
||||
.db
|
||||
.archive(
|
||||
tenant.community(),
|
||||
&target_hex,
|
||||
"self",
|
||||
&target_hex,
|
||||
None,
|
||||
None,
|
||||
&request_id,
|
||||
)
|
||||
.await
|
||||
.expect("archive identity");
|
||||
|
||||
// Arm the barrier, then spawn the stale publisher. It reads the
|
||||
// `{target}` view, reaches the hook, and blocks until released.
|
||||
let (reached_hook, release) =
|
||||
crate::handlers::side_effects::publish_test_hooks::arm(tenant.community());
|
||||
let stale_tenant = tenant.clone();
|
||||
let stale_state = state.clone();
|
||||
let stale_publisher =
|
||||
tokio::spawn(
|
||||
async move { publish_nipia_archival_list(&stale_tenant, &stale_state).await },
|
||||
);
|
||||
// Deterministically wait until the stale publisher has read `{target}`.
|
||||
reached_hook
|
||||
.await
|
||||
.expect("stale publisher reached the post-list_archived hook");
|
||||
|
||||
// canonical -> {} while the stale publisher holds its `{target}` view.
|
||||
state
|
||||
.db
|
||||
.unarchive(tenant.community(), &target_hex)
|
||||
.await
|
||||
.expect("unarchive identity");
|
||||
// Production publishes after every archive-state mutation; do the same.
|
||||
publish_nipia_archival_list(&tenant, &state)
|
||||
.await
|
||||
.expect("publish after unarchive");
|
||||
|
||||
// Release the stale publisher: it must detect drift and converge on `{}`.
|
||||
release.notify_one();
|
||||
stale_publisher
|
||||
.await
|
||||
.expect("join stale publisher")
|
||||
.expect("stale publisher converges without error");
|
||||
|
||||
let final_snapshot = state
|
||||
.db
|
||||
.query_events(&EventQuery {
|
||||
kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]),
|
||||
pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()),
|
||||
global_only: true,
|
||||
limit: Some(1),
|
||||
..EventQuery::for_community(tenant.community())
|
||||
})
|
||||
.await
|
||||
.expect("query final snapshot")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("final snapshot exists");
|
||||
|
||||
assert!(
|
||||
!final_snapshot.event.tags.iter().any(|tag| {
|
||||
let fields = tag.as_slice();
|
||||
fields.first().map(String::as_str) == Some("p")
|
||||
&& fields.get(1).map(String::as_str) == Some(target_hex.as_str())
|
||||
}),
|
||||
"a stale publisher must converge on the canonical empty set, never \
|
||||
strand the unarchived identity in the authoritative 13535"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_archive_rejects_stale_request_after_live_kind0_owner_flip() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
|
||||
@@ -3116,6 +3116,61 @@ pub async fn reconcile_channel_events(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test-only barrier hooks for [`publish_nipia_archival_list`]. Lets a test
|
||||
/// hold one publisher after it has read canonical archive state and before it
|
||||
/// replaces the head, making the stale-read/late-write race deterministic.
|
||||
/// Compiled only under `cfg(test)`; the production call site is `#[cfg(test)]`.
|
||||
///
|
||||
/// The gate is scoped to a `CommunityId`: only a publisher whose tenant matches
|
||||
/// the armed community is held. Publishers from other tenants — the rapid
|
||||
/// archive/unarchive or owner-archive regressions running in parallel under the
|
||||
/// Rust test runner — pass straight through and never consume the gate armed
|
||||
/// for the concurrent-publisher test's unique tenant.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod publish_test_hooks {
|
||||
use buzz_core::tenant::CommunityId;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::{oneshot, Notify};
|
||||
|
||||
struct Gate {
|
||||
community: CommunityId,
|
||||
arrived: oneshot::Sender<()>,
|
||||
release: Arc<Notify>,
|
||||
}
|
||||
|
||||
static GATE: Mutex<Option<Gate>> = Mutex::new(None);
|
||||
|
||||
/// Arm a one-shot barrier for `community`. Await the returned receiver to
|
||||
/// learn when the held publisher has reached the hook (i.e. has read
|
||||
/// canonical state); call `notify_one` on the returned handle to let it
|
||||
/// proceed. Only the first publisher of the matching community to reach the
|
||||
/// hook after arming is held; every other publisher passes.
|
||||
pub(crate) fn arm(community: CommunityId) -> (oneshot::Receiver<()>, Arc<Notify>) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let release = Arc::new(Notify::new());
|
||||
*GATE.lock().unwrap() = Some(Gate {
|
||||
community,
|
||||
arrived: tx,
|
||||
release: release.clone(),
|
||||
});
|
||||
(rx, release)
|
||||
}
|
||||
|
||||
pub(super) async fn after_list_archived(community: CommunityId) {
|
||||
let gate = {
|
||||
let mut slot = GATE.lock().unwrap();
|
||||
match slot.as_ref() {
|
||||
Some(gate) if gate.community == community => slot.take(),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(gate) = gate {
|
||||
let _ = gate.arrived.send(());
|
||||
gate.release.notified().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a kind:13535 archived identities list event (NIP-IA).
|
||||
///
|
||||
/// Queries all current archived identities and emits a relay-signed,
|
||||
@@ -3124,29 +3179,76 @@ pub async fn publish_nipia_archival_list(
|
||||
tenant: &TenantContext,
|
||||
state: &Arc<AppState>,
|
||||
) -> anyhow::Result<()> {
|
||||
let archived = state.db.list_archived(tenant.community()).await?;
|
||||
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
|
||||
const MAX_REPLACEMENT_ATTEMPTS: usize = 8;
|
||||
let relay_pubkey = state.relay_keypair.public_key();
|
||||
let relay_pubkey_hex = relay_pubkey.to_hex();
|
||||
|
||||
let mut tags: Vec<Tag> = Vec::with_capacity(archived.len() + 1);
|
||||
tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
|
||||
// A concurrent archive mutation can race between reading the current head and
|
||||
// replacing it. Rebuild from canonical state on rejection so an older snapshot
|
||||
// can never strand the final archive set.
|
||||
for _ in 0..MAX_REPLACEMENT_ATTEMPTS {
|
||||
let archived = state.db.list_archived(tenant.community()).await?;
|
||||
// Test-only barrier: lets a test hold a stale publisher here — after it
|
||||
// has read canonical state, before it replaces the head — so the
|
||||
// stale-read/late-write ordering the drift check must catch is
|
||||
// deterministic, not scheduler-dependent. Inert in production.
|
||||
#[cfg(test)]
|
||||
publish_test_hooks::after_list_archived(tenant.community()).await;
|
||||
let mut tags: Vec<Tag> = Vec::with_capacity(archived.len() + 1);
|
||||
tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
|
||||
|
||||
for identity in &archived {
|
||||
tags.push(
|
||||
Tag::parse(["p", &identity.pubkey])
|
||||
.map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?,
|
||||
);
|
||||
}
|
||||
for identity in &archived {
|
||||
tags.push(
|
||||
Tag::parse(["p", &identity.pubkey])
|
||||
.map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&state.relay_keypair)
|
||||
.map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?;
|
||||
// NIP-16 resolves same-second replacements by event id. Force this
|
||||
// canonical snapshot strictly past the current head instead of letting a
|
||||
// rapid archive→unarchive randomly preserve the stale archive state.
|
||||
let now = nostr::Timestamp::now().as_secs();
|
||||
let previous = state
|
||||
.db
|
||||
.query_events(&buzz_db::event::EventQuery {
|
||||
kinds: Some(vec![KIND_IA_ARCHIVED_LIST as i32]),
|
||||
pubkey: Some(relay_pubkey.to_bytes().to_vec()),
|
||||
limit: Some(1),
|
||||
global_only: true,
|
||||
..buzz_db::event::EventQuery::for_community(tenant.community())
|
||||
})
|
||||
.await?;
|
||||
let created_at = previous
|
||||
.first()
|
||||
.map(|event| (event.event.created_at.as_secs() + 1).max(now))
|
||||
.unwrap_or(now);
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "")
|
||||
.tags(tags)
|
||||
.custom_created_at(nostr::Timestamp::from(created_at))
|
||||
.sign_with_keys(&state.relay_keypair)
|
||||
.map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?;
|
||||
|
||||
let (stored, was_inserted) = state
|
||||
.db
|
||||
.replace_addressable_event(tenant.community(), &event, None)
|
||||
.await?;
|
||||
if !was_inserted {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current_archived = state.db.list_archived(tenant.community()).await?;
|
||||
let snapshot_is_current =
|
||||
archived
|
||||
.iter()
|
||||
.map(|identity| identity.pubkey.as_str())
|
||||
.eq(current_archived
|
||||
.iter()
|
||||
.map(|identity| identity.pubkey.as_str()));
|
||||
if !snapshot_is_current {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (stored, was_inserted) = state
|
||||
.db
|
||||
.replace_addressable_event(tenant.community(), &event, None)
|
||||
.await?;
|
||||
if was_inserted {
|
||||
dispatch_persistent_event(
|
||||
tenant,
|
||||
state,
|
||||
@@ -3156,13 +3258,16 @@ pub async fn publish_nipia_archival_list(
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
info!(
|
||||
archived_count = archived.len(),
|
||||
"NIP-IA archived identities list published"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
archived_count = archived.len(),
|
||||
"NIP-IA archived identities list published"
|
||||
);
|
||||
Ok(())
|
||||
anyhow::bail!(
|
||||
"failed to publish kind:{KIND_IA_ARCHIVED_LIST} after {MAX_REPLACEMENT_ATTEMPTS} concurrent replacements"
|
||||
)
|
||||
}
|
||||
|
||||
/// NIP-DV: publish the relay-signed, per-viewer DM visibility snapshot for
|
||||
|
||||
@@ -12,17 +12,50 @@
|
||||
//! see §Owner-of-Agent Requests and §Relay Processing Algorithm.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
events,
|
||||
managed_agents::try_regenerate_nest,
|
||||
relay::{
|
||||
classify_request_error, query_relay, relay_http_base_url, relay_ws_url_with_override,
|
||||
submit_event, SubmitEventResponse,
|
||||
classify_request_error, query_relay, query_relay_at, relay_api_base_url,
|
||||
relay_http_base_url, relay_ws_url, relay_ws_url_with_override, submit_event,
|
||||
workspace_relay_override, SubmitEventResponse,
|
||||
},
|
||||
};
|
||||
|
||||
/// A relay target resolved from a single workspace-override read, so a caller
|
||||
/// that performs several relay requests cannot mix two relays if the workspace
|
||||
/// override changes mid-flight.
|
||||
///
|
||||
/// `relay_ws_url_with_override` and `relay_api_base_url_with_override` each read
|
||||
/// the override independently; a workspace switch between two such reads can
|
||||
/// pair one relay's NIP-11 signer with another relay's snapshot query.
|
||||
/// Capturing both fields from one read — matching those two functions' exact
|
||||
/// precedence, including the standalone `BUZZ_RELAY_HTTP` path when no override
|
||||
/// is set — guarantees the pair is internally consistent.
|
||||
pub(crate) struct RelayTarget {
|
||||
/// Relay WebSocket URL (drives the NIP-11 fetch and the rendered footer).
|
||||
pub ws_url: String,
|
||||
/// Relay HTTP API base URL (drives `/query`).
|
||||
pub api_base_url: String,
|
||||
}
|
||||
|
||||
/// Capture the effective relay target once, before any network work.
|
||||
pub(crate) fn capture_relay_target(state: &AppState) -> RelayTarget {
|
||||
match workspace_relay_override(state) {
|
||||
Some(url) => RelayTarget {
|
||||
api_base_url: relay_http_base_url(&url),
|
||||
ws_url: url,
|
||||
},
|
||||
None => RelayTarget {
|
||||
ws_url: relay_ws_url(),
|
||||
api_base_url: relay_api_base_url(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Read `target`'s live `kind:0` event and extract the first valid NIP-OA
|
||||
@@ -139,44 +172,116 @@ pub struct UnarchiveRequest {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Submit a `kind:9035` archive request to the relay. Consent path is selected
|
||||
/// by the relay — we just attach the owner-of-agent `auth` tag when the live
|
||||
/// `kind:0` proves we own the target, so the relay can choose the `owner`
|
||||
/// path. Self and admin paths require no auth tag.
|
||||
#[tauri::command]
|
||||
pub async fn archive_identity(
|
||||
req: ArchiveRequest,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SubmitEventResponse, String> {
|
||||
let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?;
|
||||
let auth_ref = auth_tag.as_ref();
|
||||
/// Roster refresh a successful archive/unarchive triggers. Binding the action
|
||||
/// to a *type* rather than a closure selected at each call site is what closes
|
||||
/// the regression Thufir found: the command wrapper passes a value (`&app`)
|
||||
/// with no callback to construct, so the "regenerate on success" selection
|
||||
/// lives entirely inside the cores below — where the tests traverse it. The
|
||||
/// production binding is the single, irreducible `AppHandle` adapter.
|
||||
pub(crate) trait NestRegenTrigger {
|
||||
fn trigger(&self);
|
||||
}
|
||||
|
||||
impl NestRegenTrigger for AppHandle {
|
||||
fn trigger(&self) {
|
||||
try_regenerate_nest(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit `builder` to the active workspace relay, then trigger `on_success`
|
||||
/// exactly once iff the relay accepted the event.
|
||||
///
|
||||
/// This pins the shared half of the archive/unarchive → AGENTS.md-regeneration
|
||||
/// contract: regeneration is best-effort roster maintenance, so it must fire on
|
||||
/// a successful submission and must NOT fire when the submit is rejected (a
|
||||
/// rejected request changed nothing to re-render).
|
||||
async fn submit_then_regenerate(
|
||||
builder: nostr::EventBuilder,
|
||||
state: &AppState,
|
||||
on_success: impl FnOnce(),
|
||||
) -> Result<SubmitEventResponse, String> {
|
||||
let response = submit_event(builder, state).await?;
|
||||
on_success();
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `AppHandle`-free core of [`archive_identity`]: resolve the owner-of-agent
|
||||
/// `auth` tag, build the real `kind:9035` request, submit it, and trigger
|
||||
/// `regen` so a successful archive refreshes the roster.
|
||||
///
|
||||
/// The command wrapper is untestable (it needs a live Tauri runtime for its
|
||||
/// `AppHandle`), so this core owns the whole orchestration — including *binding*
|
||||
/// the regeneration trigger onto the successful-submit path. The wrapper only
|
||||
/// hands it the `AppHandle` as the trigger; a test drives the exact archive
|
||||
/// wiring with a counting trigger over a loopback relay. RED-on-revert: change
|
||||
/// `|| regen.trigger()` to `|| {}` here and
|
||||
/// `archive_core_fires_regen_only_on_accepted_submit` fails while the unarchive
|
||||
/// core test stays green.
|
||||
async fn archive_identity_core(
|
||||
req: &ArchiveRequest,
|
||||
state: &AppState,
|
||||
regen: &impl NestRegenTrigger,
|
||||
) -> Result<SubmitEventResponse, String> {
|
||||
let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?;
|
||||
let builder = events::build_archive_identity_request(
|
||||
&req.target_pubkey,
|
||||
&req.content,
|
||||
req.reason.as_deref(),
|
||||
req.replaced_by.as_deref(),
|
||||
auth_ref,
|
||||
auth_tag.as_ref(),
|
||||
)?;
|
||||
submit_event(builder, &state).await
|
||||
submit_then_regenerate(builder, state, || regen.trigger()).await
|
||||
}
|
||||
|
||||
/// Submit a `kind:9036` unarchive request to the relay.
|
||||
#[tauri::command]
|
||||
pub async fn unarchive_identity(
|
||||
req: UnarchiveRequest,
|
||||
state: State<'_, AppState>,
|
||||
/// `AppHandle`-free core of [`unarchive_identity`]: builds the real `kind:9036`
|
||||
/// request and triggers `regen` on acceptance. See [`archive_identity_core`]
|
||||
/// for why this seam is extracted. RED-on-revert: change `|| regen.trigger()`
|
||||
/// to `|| {}` here and `unarchive_core_fires_regen_only_on_accepted_submit`
|
||||
/// fails while the archive core test stays green.
|
||||
async fn unarchive_identity_core(
|
||||
req: &UnarchiveRequest,
|
||||
state: &AppState,
|
||||
regen: &impl NestRegenTrigger,
|
||||
) -> Result<SubmitEventResponse, String> {
|
||||
let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?;
|
||||
let auth_ref = auth_tag.as_ref();
|
||||
|
||||
let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?;
|
||||
let builder = events::build_unarchive_identity_request(
|
||||
&req.target_pubkey,
|
||||
&req.content,
|
||||
req.reason.as_deref(),
|
||||
auth_ref,
|
||||
auth_tag.as_ref(),
|
||||
)?;
|
||||
submit_event(builder, &state).await
|
||||
submit_then_regenerate(builder, state, || regen.trigger()).await
|
||||
}
|
||||
|
||||
/// Submit a `kind:9035` archive request to the relay. Consent path is selected
|
||||
/// by the relay — we just attach the owner-of-agent `auth` tag when the live
|
||||
/// `kind:0` proves we own the target, so the relay can choose the `owner`
|
||||
/// path. Self and admin paths require no auth tag.
|
||||
///
|
||||
/// On acceptance, refresh AGENTS.md so a just-archived agent drops from the
|
||||
/// roster without waiting for the next unrelated edit or app restart. The
|
||||
/// regen is fire-and-forget and fail-open like every other mutation site; it
|
||||
/// races the relay's kind:13535 snapshot update, so a stale render self-heals
|
||||
/// on the next regen.
|
||||
#[tauri::command]
|
||||
pub async fn archive_identity(
|
||||
req: ArchiveRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SubmitEventResponse, String> {
|
||||
archive_identity_core(&req, &state, &app).await
|
||||
}
|
||||
|
||||
/// Submit a `kind:9036` unarchive request to the relay. See
|
||||
/// [`archive_identity`]: refresh the roster so an unarchived agent reappears
|
||||
/// promptly, fail-open against the same snapshot race.
|
||||
#[tauri::command]
|
||||
pub async fn unarchive_identity(
|
||||
req: UnarchiveRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SubmitEventResponse, String> {
|
||||
unarchive_identity_core(&req, &state, &app).await
|
||||
}
|
||||
|
||||
/// If the current user is the verified NIP-OA owner of `target`, return the
|
||||
@@ -228,8 +333,18 @@ struct RelayInformationDocument {
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_relay_self(state: &AppState) -> Result<Option<String>, String> {
|
||||
let relay_url = relay_ws_url_with_override(state);
|
||||
let http_url = relay_http_base_url(&relay_url);
|
||||
fetch_relay_self_at(state, &relay_ws_url_with_override(state)).await
|
||||
}
|
||||
|
||||
/// Like [`fetch_relay_self`] but reads NIP-11 from an explicit relay WS URL
|
||||
/// instead of re-resolving the workspace override. Used by
|
||||
/// [`fetch_archived_pubkeys_at`] so the advertised signer and the snapshot
|
||||
/// query belong to the same captured relay target.
|
||||
pub(crate) async fn fetch_relay_self_at(
|
||||
state: &AppState,
|
||||
relay_url: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let http_url = relay_http_base_url(relay_url);
|
||||
let response = state
|
||||
.http_client
|
||||
.get(&http_url)
|
||||
@@ -275,46 +390,71 @@ fn archived_pubkeys_from_snapshot(snapshot: &nostr::Event) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend
|
||||
/// caches this and tests membership client-side to drive the "Archived" flair.
|
||||
/// Read the relay's latest valid `kind:13535` archive snapshot as lowercase
|
||||
/// hex pubkeys. Shared by the `list_archived_identities` command (frontend
|
||||
/// flair) and the backend nest regen (excluding archived agents from
|
||||
/// `AGENTS.md`).
|
||||
///
|
||||
/// Per NIP-IA §Client Behavior and §Snapshot and Delta Consistency, only a
|
||||
/// snapshot signed by the relay identity advertised in NIP-11 `self` can affect
|
||||
/// archive state. If the relay has no stable `self`, fail open with an empty
|
||||
/// snapshot rather than trusting unauthenticated relay-authoritative state.
|
||||
#[tauri::command]
|
||||
pub async fn list_archived_identities(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ArchivedIdentitiesSnapshot, String> {
|
||||
let Some(relay_self) = fetch_relay_self(&state).await? else {
|
||||
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
|
||||
/// archive state. Every failure path — no stable `self`, no snapshot, a bad
|
||||
/// signature or wrong author, or a query error — **fails open** with an empty
|
||||
/// set rather than trusting unauthenticated relay-authoritative state.
|
||||
pub(crate) async fn fetch_archived_pubkeys(state: &AppState) -> Vec<String> {
|
||||
fetch_archived_pubkeys_at(state, &capture_relay_target(state)).await
|
||||
}
|
||||
|
||||
/// Like [`fetch_archived_pubkeys`] but resolves both the NIP-11 signer and the
|
||||
/// snapshot query against one captured [`RelayTarget`] instead of re-reading
|
||||
/// the workspace override for each. This keeps a regeneration's advertised
|
||||
/// signer and its snapshot query on the same relay even if the workspace
|
||||
/// override changes between the two awaits.
|
||||
pub(crate) async fn fetch_archived_pubkeys_at(
|
||||
state: &AppState,
|
||||
target: &RelayTarget,
|
||||
) -> Vec<String> {
|
||||
let Ok(Some(relay_self)) = fetch_relay_self_at(state, &target.ws_url).await else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let events = query_relay(
|
||||
&state,
|
||||
let query = query_relay_at(
|
||||
state,
|
||||
&target.api_base_url,
|
||||
&[serde_json::json!({
|
||||
"authors": [relay_self.clone()],
|
||||
"kinds": [13535],
|
||||
"limit": 1,
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
let Ok(events) = query else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let Some(snapshot) = events.into_iter().next() else {
|
||||
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Defense-in-depth: the filter should already restrict author, but the
|
||||
// client must still reject malformed or wrongly signed relay state.
|
||||
if !snapshot.verify_id() || !snapshot.verify_signature() {
|
||||
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
|
||||
return vec![];
|
||||
}
|
||||
if !snapshot.pubkey.to_hex().eq_ignore_ascii_case(&relay_self) {
|
||||
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
|
||||
return vec![];
|
||||
}
|
||||
|
||||
archived_pubkeys_from_snapshot(&snapshot)
|
||||
}
|
||||
|
||||
/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend
|
||||
/// caches this and tests membership client-side to drive the "Archived" flair.
|
||||
#[tauri::command]
|
||||
pub async fn list_archived_identities(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ArchivedIdentitiesSnapshot, String> {
|
||||
Ok(ArchivedIdentitiesSnapshot {
|
||||
archived: archived_pubkeys_from_snapshot(&snapshot),
|
||||
archived: fetch_archived_pubkeys(&state).await,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -336,6 +476,29 @@ pub async fn get_relay_self(state: State<'_, AppState>) -> Result<Option<String>
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Counting [`NestRegenTrigger`] double: records how many times the core
|
||||
/// fires regeneration on the successful-submit path, standing in for the
|
||||
/// production `AppHandle` binding without a live Tauri runtime.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[derive(Default)]
|
||||
struct CountingRegen(AtomicUsize);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
impl CountingRegen {
|
||||
fn count(&self) -> usize {
|
||||
self.0.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
impl NestRegenTrigger for CountingRegen {
|
||||
fn trigger(&self) {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a fake `kind:0` with a valid NIP-OA auth tag for a fresh owner.
|
||||
fn kind0_with_auth(agent: &Keys, owner: &Keys) -> nostr::Event {
|
||||
@@ -478,4 +641,223 @@ mod tests {
|
||||
assert_eq!(minimal.content, "");
|
||||
assert!(minimal.reason.is_none());
|
||||
}
|
||||
|
||||
/// Regression for the cross-relay capture defect: `fetch_archived_pubkeys_at`
|
||||
/// must resolve BOTH the NIP-11 signer and the `/query` snapshot against the
|
||||
/// single captured [`RelayTarget`], never re-reading the live workspace
|
||||
/// override. Two loopback relays advertise distinct signers and archive
|
||||
/// distinct pubkeys; we capture relay A, then mutate the override to relay B
|
||||
/// before the fetch. Because capture happens once up front, the override's
|
||||
/// value at any later instant — including between the two archive awaits —
|
||||
/// is irrelevant by construction, so setting it to B is the strongest form
|
||||
/// of that perturbation. A must supply both the signer and the snapshot.
|
||||
///
|
||||
/// RED-on-revert: restore `fetch_archived_pubkeys` to read the override for
|
||||
/// each leg (`fetch_relay_self` + `query_relay`) and this returns B's pubkey.
|
||||
#[tokio::test]
|
||||
async fn archived_fetch_never_crosses_relays_mid_flight() {
|
||||
use crate::app_state::build_app_state;
|
||||
use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL};
|
||||
use axum::{routing::get, routing::post, Json, Router};
|
||||
|
||||
let _serial = TEST_SERIAL.lock().await;
|
||||
reset_rate_limit_gate();
|
||||
|
||||
// Build a loopback relay that advertises `relay_keys` as its NIP-11
|
||||
// `self` and serves a relay-signed 13535 snapshot archiving `archived`.
|
||||
async fn spawn_relay(relay_keys: Keys, archived: String) -> String {
|
||||
let self_hex = relay_keys.public_key().to_hex();
|
||||
let snapshot = EventBuilder::new(Kind::Custom(13535), "")
|
||||
.tags([
|
||||
Tag::parse(["-"]).unwrap(),
|
||||
Tag::parse(["p", &archived]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&relay_keys)
|
||||
.unwrap();
|
||||
let snapshot_json = serde_json::to_value(&snapshot).unwrap();
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(move || {
|
||||
let self_hex = self_hex.clone();
|
||||
async move { Json(serde_json::json!({ "self": self_hex })) }
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/query",
|
||||
post(move || {
|
||||
let snapshot_json = snapshot_json.clone();
|
||||
async move { Json(serde_json::json!([snapshot_json])) }
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.ok();
|
||||
});
|
||||
format!("ws://{addr}")
|
||||
}
|
||||
|
||||
let relay_a_keys = Keys::generate();
|
||||
let relay_b_keys = Keys::generate();
|
||||
// Distinct archived pubkeys, unrelated to either relay's signing key —
|
||||
// nostr 0.37's EventBuilder silently drops a `p` tag that references the
|
||||
// event's own signer, so the archived key must not equal the relay key.
|
||||
let archived_on_a = Keys::generate().public_key().to_hex();
|
||||
let archived_on_b = Keys::generate().public_key().to_hex();
|
||||
let relay_a = spawn_relay(relay_a_keys, archived_on_a.clone()).await;
|
||||
let relay_b = spawn_relay(relay_b_keys, archived_on_b.clone()).await;
|
||||
|
||||
let state = build_app_state();
|
||||
|
||||
// Capture relay A, then swap the override to relay B before the fetch.
|
||||
*state.relay_url_override.lock().unwrap() = Some(relay_a.clone());
|
||||
let target = capture_relay_target(&state);
|
||||
*state.relay_url_override.lock().unwrap() = Some(relay_b.clone());
|
||||
|
||||
let archived = fetch_archived_pubkeys_at(&state, &target).await;
|
||||
|
||||
assert_eq!(
|
||||
archived,
|
||||
vec![archived_on_a],
|
||||
"signer and snapshot must both come from the captured relay A, \
|
||||
never the mutated override (relay B)"
|
||||
);
|
||||
reset_rate_limit_gate();
|
||||
}
|
||||
|
||||
/// Spawn a loopback `/events` relay that answers every submit with the
|
||||
/// given `accepted` verdict, so the archive/unarchive cores see a real
|
||||
/// success or rejection over the wire. Returns the `ws://` base.
|
||||
///
|
||||
/// The literal `/events` route below is why this file carries an
|
||||
/// `EVENTS_INVENTORY` row (one occurrence, zero guard calls): a test
|
||||
/// loopback, never a production egress site.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
async fn spawn_submit_relay(accepted: bool) -> String {
|
||||
use axum::{routing::post, Json, Router};
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/events",
|
||||
post(move || async move {
|
||||
Json(serde_json::json!({
|
||||
"event_id": "e".repeat(64),
|
||||
"accepted": accepted,
|
||||
"message": if accepted { "" } else { "rejected by relay" },
|
||||
}))
|
||||
}),
|
||||
)
|
||||
// The cores resolve the owner-of-agent auth tag first, which reads
|
||||
// the target's live kind:0; answer with an empty result set so that
|
||||
// read resolves to "no owner tag" without a live upstream relay.
|
||||
.route("/query", post(|| async { Json(serde_json::json!([])) }));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.ok();
|
||||
});
|
||||
format!("ws://{addr}")
|
||||
}
|
||||
|
||||
/// Regression for the outsider-reported item 1, archive site: a successful
|
||||
/// `kind:9035` archive MUST trigger nest regeneration, and a rejected
|
||||
/// submit MUST NOT. This drives the production [`archive_identity_core`]
|
||||
/// (the exact seam the command wrapper delegates to), forwarding a counting
|
||||
/// hook against a loopback relay. RED-on-revert: replace the core's
|
||||
/// forwarded `on_success` with `|| {}` and the "fires once" assertion fails;
|
||||
/// this pins the archive command's callback independently of unarchive.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[tokio::test]
|
||||
async fn archive_core_fires_regen_only_on_accepted_submit() {
|
||||
use crate::app_state::build_app_state;
|
||||
use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL};
|
||||
|
||||
let _serial = TEST_SERIAL.lock().await;
|
||||
reset_rate_limit_gate();
|
||||
|
||||
let state = build_app_state();
|
||||
let req = ArchiveRequest {
|
||||
target_pubkey: Keys::generate().public_key().to_hex(),
|
||||
content: String::new(),
|
||||
reason: None,
|
||||
replaced_by: None,
|
||||
};
|
||||
|
||||
// Accepted archive → hook fires exactly once.
|
||||
*state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await);
|
||||
let regen = CountingRegen::default();
|
||||
let response = archive_identity_core(&req, &state, ®en)
|
||||
.await
|
||||
.expect("accepted archive returns Ok");
|
||||
assert!(response.accepted);
|
||||
assert_eq!(
|
||||
regen.count(),
|
||||
1,
|
||||
"an accepted archive must trigger regeneration exactly once"
|
||||
);
|
||||
|
||||
// Rejected submit → error propagates, hook never fires.
|
||||
*state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await);
|
||||
let regen = CountingRegen::default();
|
||||
let result = archive_identity_core(&req, &state, ®en).await;
|
||||
assert!(result.is_err(), "a rejected archive must return an error");
|
||||
assert_eq!(
|
||||
regen.count(),
|
||||
0,
|
||||
"a rejected archive changed nothing, so regeneration must not fire"
|
||||
);
|
||||
|
||||
reset_rate_limit_gate();
|
||||
}
|
||||
|
||||
/// Regression for item 1, unarchive site: mirrors
|
||||
/// [`archive_core_fires_regen_only_on_accepted_submit`] against the
|
||||
/// `kind:9036` [`unarchive_identity_core`]. RED-on-revert: replace that
|
||||
/// core's forwarded `on_success` with `|| {}` and this fails while the
|
||||
/// archive test stays green — proving each command's callback is pinned
|
||||
/// independently, not just the shared `submit_then_regenerate`.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[tokio::test]
|
||||
async fn unarchive_core_fires_regen_only_on_accepted_submit() {
|
||||
use crate::app_state::build_app_state;
|
||||
use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL};
|
||||
|
||||
let _serial = TEST_SERIAL.lock().await;
|
||||
reset_rate_limit_gate();
|
||||
|
||||
let state = build_app_state();
|
||||
let req = UnarchiveRequest {
|
||||
target_pubkey: Keys::generate().public_key().to_hex(),
|
||||
content: String::new(),
|
||||
reason: None,
|
||||
};
|
||||
|
||||
// Accepted unarchive → hook fires exactly once.
|
||||
*state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await);
|
||||
let regen = CountingRegen::default();
|
||||
let response = unarchive_identity_core(&req, &state, ®en)
|
||||
.await
|
||||
.expect("accepted unarchive returns Ok");
|
||||
assert!(response.accepted);
|
||||
assert_eq!(
|
||||
regen.count(),
|
||||
1,
|
||||
"an accepted unarchive must trigger regeneration exactly once"
|
||||
);
|
||||
|
||||
// Rejected submit → error propagates, hook never fires.
|
||||
*state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await);
|
||||
let regen = CountingRegen::default();
|
||||
let result = unarchive_identity_core(&req, &state, ®en).await;
|
||||
assert!(result.is_err(), "a rejected unarchive must return an error");
|
||||
assert_eq!(
|
||||
regen.count(),
|
||||
0,
|
||||
"a rejected unarchive changed nothing, so regeneration must not fire"
|
||||
);
|
||||
|
||||
reset_rate_limit_gate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,10 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[
|
||||
// Mock-relay route in its in-file tests; production publish goes through
|
||||
// the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`).
|
||||
("src/commands/personas/sharing.rs", 1, 0),
|
||||
// Loopback submit relay in `identity_archive.rs`'s in-file regen tests;
|
||||
// production archive/unarchive publish through the guarded boundary-1
|
||||
// funnel via `submit_event`.
|
||||
("src/commands/identity_archive.rs", 1, 0),
|
||||
];
|
||||
|
||||
// Needles are assembled at runtime so this scan file itself contains no
|
||||
|
||||
@@ -11,10 +11,12 @@ use super::{load_managed_agents, load_personas, AgentDefinition, ManagedAgentRec
|
||||
#[cfg(test)]
|
||||
use super::{BackendKind, RespondTo};
|
||||
use crate::app_state::AppState;
|
||||
use crate::relay::relay_ws_url_with_override;
|
||||
use crate::commands::{capture_relay_target, fetch_archived_pubkeys_at};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::managed_agents::discovery::known_skill_dirs;
|
||||
@@ -523,19 +525,35 @@ fn escape_md_cell(s: &str) -> String {
|
||||
s.replace('|', "\\|").replace('\n', " ")
|
||||
}
|
||||
|
||||
/// True iff the relay has archived this instance's identity. Membership is
|
||||
/// tested against the relay's `kind:13535` snapshot (lowercased hex); an empty
|
||||
/// set (relay unreachable) fails open — see [`regenerate_nest_context`].
|
||||
fn is_archived(record: &ManagedAgentRecord, archived: &HashSet<String>) -> bool {
|
||||
archived.contains(&record.pubkey.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub fn render_dynamic_section(
|
||||
personas: &[AgentDefinition],
|
||||
agents: &[ManagedAgentRecord],
|
||||
archived: &HashSet<String>,
|
||||
relay_url: &str,
|
||||
) -> String {
|
||||
let active_agents = if agents.is_empty() {
|
||||
// Every managed agent is eligible on every community — `relay_url` is a
|
||||
// legacy creation-era field that `effective_agent_relay_url()` deliberately
|
||||
// ignores, and snapshot-imported records store it empty by design. The only
|
||||
// roster filter is identity-archive.
|
||||
let live: Vec<&ManagedAgentRecord> = agents
|
||||
.iter()
|
||||
.filter(|a| !is_archived(a, archived))
|
||||
.collect();
|
||||
let active_agents = if live.is_empty() {
|
||||
"## Active Agents\n\n*(No agents deployed yet. Add agents in the Buzz desktop app.)*"
|
||||
.to_string()
|
||||
} else {
|
||||
let mut table =
|
||||
"## Active Agents\n\n| Name | Persona | How to address |\n|------|---------|----------------|"
|
||||
.to_string();
|
||||
for agent in agents {
|
||||
for agent in live {
|
||||
let role = agent
|
||||
.persona_id
|
||||
.as_deref()
|
||||
@@ -645,7 +663,124 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> {
|
||||
/// Serializes nest-context writes so a slow, stale regeneration cannot roll the
|
||||
/// file back over a newer one. This is an ordered, latest-request-wins gate —
|
||||
/// not a work coalescer: every superseded generation still performs its relay
|
||||
/// reads, then drops its result at commit time. Adding a true dirty-loop owner
|
||||
/// would be a larger change and is unwarranted at this user-driven trigger rate.
|
||||
///
|
||||
/// Each regeneration request claims a monotonic generation *synchronously* at
|
||||
/// request time (see [`NestRegenGate::claim`]), so the generation encodes
|
||||
/// program order: boot's regen is claimed before `apply_workspace`'s, an edit's
|
||||
/// regen before the next edit's. The claimed generation travels with the
|
||||
/// spawned task and gates its write in [`NestRegenGate::commit`]: a task drops
|
||||
/// its result once a *newer generation has been requested*, even if that newer
|
||||
/// generation later fails before it writes. Gating on the highest *requested*
|
||||
/// generation — not the highest *written* one — is what stops a slow, stale
|
||||
/// pre-edit render from publishing after a newer post-edit render was claimed
|
||||
/// and then failed during its relay work (which would otherwise leave the
|
||||
/// obsolete roster authoritative until the next unrelated trigger). Declared
|
||||
/// semantic: once a newer regeneration is requested, no older one publishes;
|
||||
/// if that newer one fails, the file simply waits for the next trigger.
|
||||
///
|
||||
/// `claim` and `commit` share one lock, so the "is this still the newest
|
||||
/// request?" compare is atomic with the synchronous file write. A bare atomic
|
||||
/// watermark checked separately from the write would let a new claim slip
|
||||
/// between an older task's eligibility check and its write; holding the lock
|
||||
/// across both closes that window (no `await` occurs while it is held).
|
||||
struct NestRegenGate {
|
||||
/// Highest generation *requested* so far (`0` = none yet). Advanced by
|
||||
/// [`claim`] and read by [`commit`]; guarding both under this single lock
|
||||
/// keeps the eligibility compare atomic with the file write.
|
||||
highest_requested: Mutex<u64>,
|
||||
}
|
||||
|
||||
impl NestRegenGate {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
highest_requested: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim the next generation. Call synchronously at request time so the
|
||||
/// value reflects when the regeneration was requested, not when its task
|
||||
/// happens to run. Advancing the shared watermark here is what lets a later
|
||||
/// [`commit`] recognize — and drop — any older generation's stale render.
|
||||
fn claim(&self) -> u64 {
|
||||
let mut requested = self
|
||||
.highest_requested
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*requested += 1;
|
||||
*requested
|
||||
}
|
||||
|
||||
/// Non-blocking [`claim`] against the *exact* lock `claim` takes. Returns
|
||||
/// `Some(generation)` if it acquired the lock — i.e. a claim could proceed
|
||||
/// with no contention — or `None` if the lock is already held, meaning a
|
||||
/// concurrent claim would block on it. Because `claim` and `commit` share
|
||||
/// `highest_requested`, calling this from inside `commit_hooked`'s
|
||||
/// under-lock hook reports `None`: the eligibility compare and the write
|
||||
/// are serialized against any new claim. A design that advanced the
|
||||
/// watermark under a separate lock (or a lock-free atomic) would report
|
||||
/// `Some` here — the regression this probe proves absent, with no reliance
|
||||
/// on elapsed time or thread scheduling.
|
||||
#[cfg(test)]
|
||||
fn try_claim(&self) -> Option<u64> {
|
||||
match self.highest_requested.try_lock() {
|
||||
Ok(mut requested) => {
|
||||
*requested += 1;
|
||||
Some(*requested)
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => None,
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => {
|
||||
let mut requested = poisoned.into_inner();
|
||||
*requested += 1;
|
||||
Some(*requested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit `content` for `generation`, dropping the write once a newer
|
||||
/// generation has been *requested* (regardless of whether that newer
|
||||
/// generation has written or ever will). Returns whether the file was
|
||||
/// written. The lock spans the compare and the write so the check-and-write
|
||||
/// is atomic and no await occurs while it is held.
|
||||
fn commit(&self, agents_md: &Path, content: &str, generation: u64) -> io::Result<bool> {
|
||||
self.commit_hooked(agents_md, content, generation, || {})
|
||||
}
|
||||
|
||||
/// [`commit`] with a hook invoked while the lock is held, after the
|
||||
/// eligibility compare and before the write. Production passes a no-op, so
|
||||
/// this is exactly [`commit`]; tests pass a hook that calls [`try_claim`]
|
||||
/// to prove no claim can land inside the compare-then-write window — the
|
||||
/// probe reports the lock held here, whereas the flawed
|
||||
/// separate-watermark/separate-write-lock design would report it free. The
|
||||
/// `impl FnOnce` monomorphizes the no-op away.
|
||||
fn commit_hooked(
|
||||
&self,
|
||||
agents_md: &Path,
|
||||
content: &str,
|
||||
generation: u64,
|
||||
under_lock: impl FnOnce(),
|
||||
) -> io::Result<bool> {
|
||||
let requested = self
|
||||
.highest_requested
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("nest regen gate lock poisoned"))?;
|
||||
if generation < *requested {
|
||||
return Ok(false);
|
||||
}
|
||||
under_lock();
|
||||
upsert_managed_section(agents_md, content)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide ordered write gate for nest-context regeneration.
|
||||
static NEST_REGEN: NestRegenGate = NestRegenGate::new();
|
||||
|
||||
pub async fn regenerate_nest_context(app: &AppHandle, generation: u64) -> Result<(), String> {
|
||||
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
|
||||
let agents_md = nest.join("AGENTS.md");
|
||||
|
||||
@@ -656,23 +791,51 @@ pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> {
|
||||
let personas = load_personas(app)?;
|
||||
let agents = load_managed_agents(app)?;
|
||||
let state = app.state::<AppState>();
|
||||
let relay_url = relay_ws_url_with_override(&state);
|
||||
let content = render_dynamic_section(&personas, &agents, &relay_url);
|
||||
upsert_managed_section(&agents_md, &content)
|
||||
// Capture the relay target once, before any network work, so this
|
||||
// generation's rendered footer, NIP-11 signer, and snapshot query all
|
||||
// belong to one relay even if a workspace switch changes the override
|
||||
// between the two archive awaits below.
|
||||
let target = capture_relay_target(&state);
|
||||
// Identity-archived agents live only in the relay's `kind:13535` snapshot;
|
||||
// local records all read `is_active: true`. Fails open (empty set → render
|
||||
// everyone) so an unreachable relay can't blank the roster. The archive read
|
||||
// uses the same captured target as the rendered relay; a later generation's
|
||||
// task always wins the commit, so a fallback-relay boot render cannot bury a
|
||||
// later apply_workspace render.
|
||||
let archived: HashSet<String> = fetch_archived_pubkeys_at(&state, &target)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect();
|
||||
let content = render_dynamic_section(&personas, &agents, &archived, &target.ws_url);
|
||||
NEST_REGEN
|
||||
.commit(&agents_md, &content, generation)
|
||||
.map_err(|e| format!("regenerate nest context: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convenience wrapper: regenerates nest context, logging a warning on failure.
|
||||
/// Convenience wrapper: claims a regeneration generation, then regenerates on a
|
||||
/// spawned task, logging a warning on failure.
|
||||
///
|
||||
/// All call sites treat regeneration as fire-and-forget — agents run fine with
|
||||
/// a stale AGENTS.md, so we warn and continue rather than propagating the error.
|
||||
/// The generation is claimed *here*, synchronously, so it encodes call order;
|
||||
/// the spawned task carries it into [`NestRegenGate::commit`], which drops
|
||||
/// a stale render rather than letting a slow task overwrite a newer file.
|
||||
/// Archive/unarchive trigger this directly, but the regen races the relay's
|
||||
/// `kind:13535` snapshot update, so a just-archived agent may still linger for
|
||||
/// one cycle until the next regen (any agent/team edit or the next launch).
|
||||
pub fn try_regenerate_nest(app: &AppHandle) {
|
||||
if let Err(error) = regenerate_nest_context(app) {
|
||||
eprintln!("buzz-desktop: nest context regeneration failed: {error}");
|
||||
}
|
||||
let generation = NEST_REGEN.claim();
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(error) = regenerate_nest_context(&app, generation).await {
|
||||
eprintln!("buzz-desktop: nest context regeneration failed: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
713
desktop/src-tauri/src/managed_agents/nest/render_tests.rs
Normal file
713
desktop/src-tauri/src/managed_agents/nest/render_tests.rs
Normal file
@@ -0,0 +1,713 @@
|
||||
//! Tests for the dynamic AGENTS.md section renderer, the managed-section
|
||||
//! upsert, and the regeneration gate. Split from `tests.rs` to keep
|
||||
//! each test file under the repository's per-file line ratchet.
|
||||
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Relay URL passed to render calls. Since the roster no longer filters on
|
||||
/// `relay_url`, this is only echoed into the Workspace footer.
|
||||
const TEST_RELAY: &str = "ws://example.com:3000";
|
||||
|
||||
fn make_persona(id: &str, display_name: &str) -> AgentDefinition {
|
||||
AgentDefinition {
|
||||
id: id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: String::new(),
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
name_pool: vec![],
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
shared: false,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
catalog_source: None,
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
respond_to: None,
|
||||
respond_to_allowlist: Vec::new(),
|
||||
parallelism: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
|
||||
ManagedAgentRecord {
|
||||
pubkey: String::new(),
|
||||
name: name.to_string(),
|
||||
persona_id: persona_id.map(|s| s.to_string()),
|
||||
private_key_nsec: String::new(),
|
||||
auth_tag: None,
|
||||
relay_url: TEST_RELAY.to_string(),
|
||||
avatar_url: None,
|
||||
acp_command: String::new(),
|
||||
agent_command: String::new(),
|
||||
agent_command_override: None,
|
||||
agent_args: vec![],
|
||||
mcp_command: String::new(),
|
||||
turn_timeout_seconds: 0,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: 1,
|
||||
system_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
persona_source_version: None,
|
||||
start_on_app_launch: false,
|
||||
auto_restart_on_config_change: true,
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
last_error_code: None,
|
||||
respond_to: RespondTo::default(),
|
||||
respond_to_allowlist: vec![],
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
shared: false,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
catalog_source: None,
|
||||
definition_respond_to: None,
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
effort_level: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_with_agents() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
assert!(output.contains("| Kit | Builder | @Kit |"));
|
||||
assert!(output.contains("| Name | Persona | How to address |"));
|
||||
assert!(output.contains("## Workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_empty() {
|
||||
let output = render_dynamic_section(&[], &[], &HashSet::new(), TEST_RELAY);
|
||||
assert!(output.contains("No agents deployed yet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_agent_no_persona() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Scout", Some("nonexistent"))];
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
assert!(output.contains("| Scout | — | @Scout |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_excludes_archived_agents() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let mut live = make_agent("Live", Some("p1"));
|
||||
live.pubkey = "aa".repeat(32);
|
||||
let mut gone = make_agent("Archived", Some("p1"));
|
||||
gone.pubkey = "bb".repeat(32);
|
||||
let archived: HashSet<String> = [gone.pubkey.clone()].into_iter().collect();
|
||||
|
||||
let output = render_dynamic_section(&personas, &[live, gone], &archived, TEST_RELAY);
|
||||
|
||||
assert!(output.contains("| Live | Builder | @Live |"));
|
||||
assert!(
|
||||
!output.contains("Archived"),
|
||||
"archived agent must not render"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_archived_match_is_case_insensitive() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let mut gone = make_agent("Archived", Some("p1"));
|
||||
gone.pubkey = "AB".repeat(32); // uppercase hex in the record
|
||||
// Snapshot pubkeys are lowercased by `archived_pubkeys_from_snapshot`.
|
||||
let archived: HashSet<String> = ["ab".repeat(32)].into_iter().collect();
|
||||
|
||||
let output = render_dynamic_section(&personas, &[gone], &archived, TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("No agents deployed yet"),
|
||||
"all-archived roster renders the empty placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_empty_archived_set_renders_all() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let mut a = make_agent("Kit", Some("p1"));
|
||||
a.pubkey = "cc".repeat(32);
|
||||
// Fail-open: an empty snapshot (relay unreachable) must render everyone.
|
||||
let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY);
|
||||
assert!(output.contains("| Kit | Builder | @Kit |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_keeps_agent_with_legacy_foreign_relay_pin() {
|
||||
// `relay_url` is a legacy creation-era field that `effective_agent_relay_url()`
|
||||
// deliberately ignores — every agent is eligible on every community. A record
|
||||
// whose stored pin points at a now-defunct relay must still render on the
|
||||
// active workspace; only identity-archive removes an agent.
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let here = make_agent("Local", Some("p1"));
|
||||
let mut elsewhere = make_agent("Foreign", Some("p1"));
|
||||
elsewhere.relay_url = "wss://defunct.communities.buzz.xyz".to_string();
|
||||
|
||||
let output = render_dynamic_section(&personas, &[here, elsewhere], &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(output.contains("| Local | Builder | @Local |"));
|
||||
assert!(
|
||||
output.contains("| Foreign | Builder | @Foreign |"),
|
||||
"a legacy foreign relay pin must not hide an agent — the pin is ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_keeps_snapshot_imported_agent_with_empty_relay_pin() {
|
||||
// Snapshot-imported records store `relay_url: ""` by design; they resolve
|
||||
// to the workspace relay at runtime. Such an agent must appear on the active
|
||||
// workspace, not be hidden by an empty pin.
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let mut imported = make_agent("Imported", Some("p1"));
|
||||
imported.relay_url = String::new();
|
||||
|
||||
let output = render_dynamic_section(&personas, &[imported], &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("| Imported | Builder | @Imported |"),
|
||||
"an empty relay_url (snapshot-import shape) must still render"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_with_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\nsome content\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n<!-- END BUZZ MANAGED -->\n\nafter\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("new section"));
|
||||
assert!(!result.contains("old section"));
|
||||
assert!(result.contains("# Header"));
|
||||
assert!(result.contains("some content"));
|
||||
assert!(result.contains("after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_without_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n\nexisting content\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "injected section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("# Header"));
|
||||
assert!(result.contains("existing content"));
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("injected section"));
|
||||
let begin_pos = result.find("<!-- BEGIN BUZZ MANAGED").unwrap();
|
||||
let header_pos = result.find("# Header").unwrap();
|
||||
assert!(
|
||||
header_pos < begin_pos,
|
||||
"original content should precede the managed section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_no_tmp_leftover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "content").unwrap();
|
||||
|
||||
// Verify no stray temp files in the directory
|
||||
let entries: Vec<_> = fs::read_dir(tmp.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"only AGENTS.md should remain, no temp files"
|
||||
);
|
||||
assert_eq!(entries[0].file_name(), "AGENTS.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_end_before_begin() {
|
||||
// An END marker that precedes a BEGIN marker forms no valid ordered pair.
|
||||
// find_managed_markers returns None (BEGIN found, but no END after it),
|
||||
// so the orphan BEGIN line is stripped and a new block is appended.
|
||||
// The stray END line and content between END and BEGIN remain in the file
|
||||
// because strip_orphan_begin_marker only removes the BEGIN line itself.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- END BUZZ MANAGED -->\nsome middle content\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(result.contains("# Header"), "original header must survive");
|
||||
assert!(
|
||||
result.contains("new section"),
|
||||
"new content must be present"
|
||||
);
|
||||
assert!(
|
||||
result.contains("some middle content"),
|
||||
"content between markers must survive"
|
||||
);
|
||||
|
||||
// Exactly one BEGIN marker in the output (the orphan was stripped, new one appended).
|
||||
assert_eq!(
|
||||
result.matches(BEGIN_MARKER).count(),
|
||||
1,
|
||||
"exactly one BEGIN marker after orphan cleanup"
|
||||
);
|
||||
|
||||
// The single BEGIN marker must have a matching END marker after it.
|
||||
let begin_pos = result
|
||||
.find(BEGIN_MARKER)
|
||||
.expect("BEGIN marker must be present");
|
||||
let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p);
|
||||
assert!(
|
||||
end_pos.is_some(),
|
||||
"an END marker must appear after the appended BEGIN marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_begin_only_no_end() {
|
||||
// A file with BEGIN but no END has an orphan marker.
|
||||
// find_managed_markers returns None (no END found after BEGIN),
|
||||
// so strip_orphan_begin_marker removes the BEGIN line.
|
||||
// Content that followed the orphan BEGIN is preserved (only the marker line is stripped,
|
||||
// not the body that came after it).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\nsome content\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\norphaned section without end marker\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "fresh section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(result.contains("# Header"), "original header must survive");
|
||||
assert!(
|
||||
result.contains("some content"),
|
||||
"original body must survive"
|
||||
);
|
||||
assert!(
|
||||
result.contains("fresh section"),
|
||||
"new content must be present"
|
||||
);
|
||||
|
||||
let begin_pos = result
|
||||
.find(BEGIN_MARKER)
|
||||
.expect("BEGIN marker must be present");
|
||||
let end_pos = result.find(END_MARKER).expect("END marker must be present");
|
||||
assert!(
|
||||
begin_pos < end_pos,
|
||||
"the appended BEGIN marker must precede the appended END marker"
|
||||
);
|
||||
|
||||
// Exactly one BEGIN marker after orphan cleanup.
|
||||
assert_eq!(
|
||||
result.matches(BEGIN_MARKER).count(),
|
||||
1,
|
||||
"exactly one BEGIN marker after orphan cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_duplicate_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nfirst block\n<!-- END BUZZ MANAGED -->\n\nbetween blocks\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nsecond block\n<!-- END BUZZ MANAGED -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "replaced").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains("replaced"),
|
||||
"replacement content must be present"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("first block"),
|
||||
"first block must be replaced"
|
||||
);
|
||||
assert!(
|
||||
result.contains("second block"),
|
||||
"second pair content must survive"
|
||||
);
|
||||
assert!(
|
||||
result.contains("between blocks"),
|
||||
"text between pairs must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_marker_in_code_block() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
// Indented by 4 spaces — not at column 0, so should NOT match as a real marker.
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n <!-- BEGIN BUZZ MANAGED — some indented marker -->\n\nReal content here\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "appended content").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains(" <!-- BEGIN BUZZ MANAGED — some indented marker -->"),
|
||||
"indented marker inside code block must be preserved verbatim"
|
||||
);
|
||||
assert!(
|
||||
result.contains("appended content"),
|
||||
"new content must be appended"
|
||||
);
|
||||
assert!(
|
||||
result.contains("Real content here"),
|
||||
"existing body must survive"
|
||||
);
|
||||
|
||||
// The real markers appended at the end must be at line-start (column 0).
|
||||
let begin_pos = result
|
||||
.find("<!-- BEGIN BUZZ MANAGED — regenerated")
|
||||
.expect("regenerated BEGIN marker must be present");
|
||||
assert!(
|
||||
begin_pos == 0 || result.as_bytes()[begin_pos - 1] == b'\n',
|
||||
"appended BEGIN marker must be at line start"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pipe_in_agent_name() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit|Pro", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("Kit\\|Pro"),
|
||||
"pipe in agent name must be escaped as \\|"
|
||||
);
|
||||
// An unescaped bare `|` immediately adjacent to "Kit|Pro" would break table parsing.
|
||||
assert!(
|
||||
!output.contains("| Kit|Pro |"),
|
||||
"unescaped pipe in agent name must not appear as a cell boundary"
|
||||
);
|
||||
|
||||
// The row must start and end with `|` and the escaped name and address must appear.
|
||||
let kit_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Kit\\|Pro"))
|
||||
.expect("Kit\\|Pro row must be present");
|
||||
assert!(kit_row.starts_with('|'), "row must start with |");
|
||||
assert!(kit_row.ends_with('|'), "row must end with |");
|
||||
assert!(
|
||||
kit_row.contains("@Kit\\|Pro"),
|
||||
"address cell must use escaped name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_newline_in_persona_name() {
|
||||
let personas = vec![make_persona("p1", "Builder\nExpert")];
|
||||
let agents = vec![make_agent("Scout", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("Builder Expert"),
|
||||
"newline in persona display_name must be replaced with a space"
|
||||
);
|
||||
|
||||
// The table row for Scout must be a single line (no embedded newline).
|
||||
let scout_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Scout"))
|
||||
.expect("Scout row must be present");
|
||||
assert!(
|
||||
scout_row.contains("Builder Expert"),
|
||||
"persona name with newline replaced by space must appear on the Scout row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nexisting section\n<!-- END BUZZ MANAGED -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "same content").unwrap();
|
||||
let after_first = fs::read_to_string(&file).unwrap();
|
||||
|
||||
upsert_managed_section(&file, "same content").unwrap();
|
||||
let after_second = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
after_first, after_second,
|
||||
"upsert must be idempotent: second call must not alter the file"
|
||||
);
|
||||
}
|
||||
|
||||
/// Write an AGENTS.md skeleton with an empty managed section and return its path.
|
||||
fn agents_md_with_markers(dir: &Path) -> PathBuf {
|
||||
let file = dir.join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\n\n<!-- END BUZZ MANAGED -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_newer_generation_wins_over_a_stale_finisher() {
|
||||
// Models the CRUD race: generation A snapshots pre-edit state and its relay
|
||||
// fetch is slow; generation B snapshots post-edit state and commits first.
|
||||
// When A finally finishes and commits LAST, its lower generation is dropped
|
||||
// so the file still reflects B. Ordering of *finishing* is the only variable —
|
||||
// the generation, claimed at request time, decides the winner.
|
||||
let gate = NestRegenGate::new();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = agents_md_with_markers(tmp.path());
|
||||
|
||||
let gen_a = gate.claim(); // pre-edit request
|
||||
let gen_b = gate.claim(); // post-edit request
|
||||
assert!(gen_a < gen_b);
|
||||
|
||||
// B (newer) commits first.
|
||||
assert!(gate.commit(&file, "post-edit roster", gen_b).unwrap());
|
||||
// A (older) finishes last and must be dropped.
|
||||
assert!(
|
||||
!gate.commit(&file, "pre-edit roster", gen_a).unwrap(),
|
||||
"a stale (lower-generation) render must not overwrite a newer one"
|
||||
);
|
||||
|
||||
let content = fs::read_to_string(&file).unwrap();
|
||||
assert!(content.contains("post-edit roster"));
|
||||
assert!(
|
||||
!content.contains("pre-edit roster"),
|
||||
"final file must reflect the newer generation, not the stale finisher"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_boot_fallback_relay_cannot_bury_apply_workspace_relay() {
|
||||
// Models boot→apply_workspace relay switching: the boot regen (generation 1,
|
||||
// fallback relay) is claimed first but finishes last; the apply_workspace
|
||||
// regen (generation 2, workspace relay) commits first. The workspace relay
|
||||
// render must survive even though the fallback-relay task writes afterward.
|
||||
let gate = NestRegenGate::new();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = agents_md_with_markers(tmp.path());
|
||||
|
||||
let boot_gen = gate.claim(); // boot, fallback relay
|
||||
let apply_gen = gate.claim(); // apply_workspace, workspace relay
|
||||
|
||||
// apply_workspace's render lands first.
|
||||
assert!(gate
|
||||
.commit(
|
||||
&file,
|
||||
"## Workspace\n- Relay: wss://workspace.example",
|
||||
apply_gen,
|
||||
)
|
||||
.unwrap());
|
||||
// Boot's slower fallback-relay render finishes last and is dropped.
|
||||
assert!(!gate
|
||||
.commit(
|
||||
&file,
|
||||
"## Workspace\n- Relay: wss://fallback.example",
|
||||
boot_gen,
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
let content = fs::read_to_string(&file).unwrap();
|
||||
assert!(content.contains("wss://workspace.example"));
|
||||
assert!(
|
||||
!content.contains("wss://fallback.example"),
|
||||
"the fallback-relay boot render must not overwrite the workspace-relay render"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_failed_newer_request_still_supersedes_older_snapshot() {
|
||||
// Carl 4954831197, case 1: a newer request that never writes must still
|
||||
// permanently supersede an older snapshot. gen1 (pre-edit) is claimed and
|
||||
// its relay work is slow; an edit claims gen2 (post-edit); gen2 then FAILS
|
||||
// during its relay work, so it never commits. When gen1 finally finishes,
|
||||
// it must NOT publish its obsolete roster — gating on highest-*requested*
|
||||
// (advanced by gen2's claim) drops it, whereas gating on highest-*written*
|
||||
// (0, since gen2 never wrote) would wrongly let gen1 publish.
|
||||
let gate = NestRegenGate::new();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = agents_md_with_markers(tmp.path());
|
||||
|
||||
let gen1 = gate.claim(); // pre-edit request
|
||||
let gen2 = gate.claim(); // post-edit request
|
||||
assert!(gen1 < gen2);
|
||||
|
||||
// gen2 fails during relay work and never reaches commit — nothing written.
|
||||
|
||||
// gen1 finishes last; its stale render must be dropped.
|
||||
assert!(
|
||||
!gate.commit(&file, "pre-edit roster", gen1).unwrap(),
|
||||
"an older snapshot must not publish once a newer generation was requested, \
|
||||
even if that newer generation failed before writing"
|
||||
);
|
||||
|
||||
let content = fs::read_to_string(&file).unwrap();
|
||||
assert!(
|
||||
!content.contains("pre-edit roster"),
|
||||
"the obsolete pre-edit roster must never become authoritative"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_claim_at_the_older_tasks_cutover_supersedes_it() {
|
||||
// Carl 4954831197, case 2: a claim arriving at the older task's commit
|
||||
// cutover must not slip between the eligibility compare and the write.
|
||||
// gen1 becomes eligible and enters `commit`; while it holds the lock
|
||||
// (after the compare, before the write) a claim is attempted. The correct
|
||||
// single-lock gate shares `highest_requested` between `claim` and
|
||||
// `commit`, so that claim cannot acquire the lock until gen1's write
|
||||
// releases it — the flawed separate-watermark/separate-write-lock design
|
||||
// Carl warned about would let the claim proceed immediately.
|
||||
//
|
||||
// Determinism: the under-lock hook calls `try_claim`, a non-blocking claim
|
||||
// against the exact lock `claim` takes, and asserts it reports the lock
|
||||
// held (`None`). This is a direct statement about the gate's locking with
|
||||
// no thread, channel, or sleep — the correct design necessarily returns
|
||||
// `None` and the separate-watermark design necessarily returns `Some`, so
|
||||
// the discriminator cannot be flipped by scheduler timing.
|
||||
let gate = NestRegenGate::new();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = agents_md_with_markers(tmp.path());
|
||||
|
||||
let gen1 = gate.claim();
|
||||
|
||||
let wrote_gen1 = gate
|
||||
.commit_hooked(&file, "gen1 roster", gen1, || {
|
||||
// We are past the eligibility compare and hold the lock. A claim
|
||||
// attempted now must find the shared lock held — proving the
|
||||
// compare and the write are atomic against any new claim.
|
||||
assert!(
|
||||
gate.try_claim().is_none(),
|
||||
"a claim must not acquire the gate while an older commit holds \
|
||||
the shared lock between its eligibility check and its write — \
|
||||
the eligibility compare is not atomic with the write \
|
||||
(separate-watermark design)"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
wrote_gen1,
|
||||
"gen1 was still the highest request when it entered commit, so its write \
|
||||
is legitimate; the newer request only lands after the lock releases"
|
||||
);
|
||||
|
||||
// The lock is free once commit returns, so a newer request now claims and
|
||||
// may publish over gen1.
|
||||
let gen2 = gate.claim();
|
||||
assert!(gen1 < gen2);
|
||||
assert!(gate.commit(&file, "gen2 roster", gen2).unwrap());
|
||||
|
||||
let content = fs::read_to_string(&file).unwrap();
|
||||
assert!(content.contains("gen2 roster"));
|
||||
assert!(!content.contains("gen1 roster"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_equal_generation_is_allowed() {
|
||||
// The gate rejects only strictly-lower generations. Re-committing the same
|
||||
// generation (e.g. a retried request) is permitted and refreshes the file.
|
||||
let gate = NestRegenGate::new();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = agents_md_with_markers(tmp.path());
|
||||
|
||||
let gen = gate.claim();
|
||||
assert!(gate.commit(&file, "first", gen).unwrap());
|
||||
assert!(
|
||||
gate.commit(&file, "second", gen).unwrap(),
|
||||
"an equal generation must still be allowed to write"
|
||||
);
|
||||
|
||||
let content = fs::read_to_string(&file).unwrap();
|
||||
assert!(content.contains("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_poisoned_lock_returns_error_instead_of_panicking() {
|
||||
// A poisoned gate lock must degrade to an io::Error so the fire-and-forget
|
||||
// caller warns and continues, never panicking the desktop process (root
|
||||
// AGENTS.md: no new expect() in production paths). Poison the lock by
|
||||
// panicking a thread while it holds the guard, then assert commit yields
|
||||
// Err rather than unwinding.
|
||||
let gate = std::sync::Arc::new(NestRegenGate::new());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = agents_md_with_markers(tmp.path());
|
||||
let gen = gate.claim();
|
||||
|
||||
let poisoner = gate.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = poisoner.highest_requested.lock().unwrap();
|
||||
panic!("poison the gate lock");
|
||||
})
|
||||
.join();
|
||||
|
||||
let result = gate.commit(&file, "after poison", gen);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a poisoned lock must surface as an error, not a panic"
|
||||
);
|
||||
}
|
||||
@@ -422,416 +422,6 @@ fn ensure_cli_symlink_does_not_clobber_regular_file_dev() {
|
||||
);
|
||||
}
|
||||
|
||||
fn make_persona(id: &str, display_name: &str) -> AgentDefinition {
|
||||
AgentDefinition {
|
||||
id: id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: String::new(),
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
name_pool: vec![],
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
shared: false,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
catalog_source: None,
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
respond_to: None,
|
||||
respond_to_allowlist: Vec::new(),
|
||||
parallelism: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
|
||||
ManagedAgentRecord {
|
||||
pubkey: String::new(),
|
||||
name: name.to_string(),
|
||||
persona_id: persona_id.map(|s| s.to_string()),
|
||||
private_key_nsec: String::new(),
|
||||
auth_tag: None,
|
||||
relay_url: String::new(),
|
||||
avatar_url: None,
|
||||
acp_command: String::new(),
|
||||
agent_command: String::new(),
|
||||
agent_command_override: None,
|
||||
agent_args: vec![],
|
||||
mcp_command: String::new(),
|
||||
turn_timeout_seconds: 0,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: 1,
|
||||
system_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
persona_source_version: None,
|
||||
start_on_app_launch: false,
|
||||
auto_restart_on_config_change: true,
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
last_error_code: None,
|
||||
respond_to: RespondTo::default(),
|
||||
respond_to_allowlist: vec![],
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
shared: false,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
catalog_source: None,
|
||||
definition_respond_to: None,
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
effort_level: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_with_agents() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
assert!(output.contains("| Kit | Builder | @Kit |"));
|
||||
assert!(output.contains("| Name | Persona | How to address |"));
|
||||
assert!(output.contains("## Workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_empty() {
|
||||
let output = render_dynamic_section(&[], &[], "ws://example.com:3000");
|
||||
assert!(output.contains("No agents deployed yet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_agent_no_persona() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Scout", Some("nonexistent"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
assert!(output.contains("| Scout | — | @Scout |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_with_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\nsome content\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n<!-- END BUZZ MANAGED -->\n\nafter\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("new section"));
|
||||
assert!(!result.contains("old section"));
|
||||
assert!(result.contains("# Header"));
|
||||
assert!(result.contains("some content"));
|
||||
assert!(result.contains("after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_without_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n\nexisting content\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "injected section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("# Header"));
|
||||
assert!(result.contains("existing content"));
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("injected section"));
|
||||
let begin_pos = result.find("<!-- BEGIN BUZZ MANAGED").unwrap();
|
||||
let header_pos = result.find("# Header").unwrap();
|
||||
assert!(
|
||||
header_pos < begin_pos,
|
||||
"original content should precede the managed section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_no_tmp_leftover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "content").unwrap();
|
||||
|
||||
// Verify no stray temp files in the directory
|
||||
let entries: Vec<_> = fs::read_dir(tmp.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"only AGENTS.md should remain, no temp files"
|
||||
);
|
||||
assert_eq!(entries[0].file_name(), "AGENTS.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_end_before_begin() {
|
||||
// An END marker that precedes a BEGIN marker forms no valid ordered pair.
|
||||
// find_managed_markers returns None (BEGIN found, but no END after it),
|
||||
// so the orphan BEGIN line is stripped and a new block is appended.
|
||||
// The stray END line and content between END and BEGIN remain in the file
|
||||
// because strip_orphan_begin_marker only removes the BEGIN line itself.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- END BUZZ MANAGED -->\nsome middle content\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(result.contains("# Header"), "original header must survive");
|
||||
assert!(
|
||||
result.contains("new section"),
|
||||
"new content must be present"
|
||||
);
|
||||
assert!(
|
||||
result.contains("some middle content"),
|
||||
"content between markers must survive"
|
||||
);
|
||||
|
||||
// Exactly one BEGIN marker in the output (the orphan was stripped, new one appended).
|
||||
assert_eq!(
|
||||
result.matches(BEGIN_MARKER).count(),
|
||||
1,
|
||||
"exactly one BEGIN marker after orphan cleanup"
|
||||
);
|
||||
|
||||
// The single BEGIN marker must have a matching END marker after it.
|
||||
let begin_pos = result
|
||||
.find(BEGIN_MARKER)
|
||||
.expect("BEGIN marker must be present");
|
||||
let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p);
|
||||
assert!(
|
||||
end_pos.is_some(),
|
||||
"an END marker must appear after the appended BEGIN marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_begin_only_no_end() {
|
||||
// A file with BEGIN but no END has an orphan marker.
|
||||
// find_managed_markers returns None (no END found after BEGIN),
|
||||
// so strip_orphan_begin_marker removes the BEGIN line.
|
||||
// Content that followed the orphan BEGIN is preserved (only the marker line is stripped,
|
||||
// not the body that came after it).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\nsome content\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\norphaned section without end marker\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "fresh section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(result.contains("# Header"), "original header must survive");
|
||||
assert!(
|
||||
result.contains("some content"),
|
||||
"original body must survive"
|
||||
);
|
||||
assert!(
|
||||
result.contains("fresh section"),
|
||||
"new content must be present"
|
||||
);
|
||||
|
||||
let begin_pos = result
|
||||
.find(BEGIN_MARKER)
|
||||
.expect("BEGIN marker must be present");
|
||||
let end_pos = result.find(END_MARKER).expect("END marker must be present");
|
||||
assert!(
|
||||
begin_pos < end_pos,
|
||||
"the appended BEGIN marker must precede the appended END marker"
|
||||
);
|
||||
|
||||
// Exactly one BEGIN marker after orphan cleanup.
|
||||
assert_eq!(
|
||||
result.matches(BEGIN_MARKER).count(),
|
||||
1,
|
||||
"exactly one BEGIN marker after orphan cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_duplicate_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nfirst block\n<!-- END BUZZ MANAGED -->\n\nbetween blocks\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nsecond block\n<!-- END BUZZ MANAGED -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "replaced").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains("replaced"),
|
||||
"replacement content must be present"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("first block"),
|
||||
"first block must be replaced"
|
||||
);
|
||||
assert!(
|
||||
result.contains("second block"),
|
||||
"second pair content must survive"
|
||||
);
|
||||
assert!(
|
||||
result.contains("between blocks"),
|
||||
"text between pairs must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_marker_in_code_block() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
// Indented by 4 spaces — not at column 0, so should NOT match as a real marker.
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n <!-- BEGIN BUZZ MANAGED — some indented marker -->\n\nReal content here\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "appended content").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains(" <!-- BEGIN BUZZ MANAGED — some indented marker -->"),
|
||||
"indented marker inside code block must be preserved verbatim"
|
||||
);
|
||||
assert!(
|
||||
result.contains("appended content"),
|
||||
"new content must be appended"
|
||||
);
|
||||
assert!(
|
||||
result.contains("Real content here"),
|
||||
"existing body must survive"
|
||||
);
|
||||
|
||||
// The real markers appended at the end must be at line-start (column 0).
|
||||
let begin_pos = result
|
||||
.find("<!-- BEGIN BUZZ MANAGED — regenerated")
|
||||
.expect("regenerated BEGIN marker must be present");
|
||||
assert!(
|
||||
begin_pos == 0 || result.as_bytes()[begin_pos - 1] == b'\n',
|
||||
"appended BEGIN marker must be at line start"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pipe_in_agent_name() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit|Pro", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
|
||||
assert!(
|
||||
output.contains("Kit\\|Pro"),
|
||||
"pipe in agent name must be escaped as \\|"
|
||||
);
|
||||
// An unescaped bare `|` immediately adjacent to "Kit|Pro" would break table parsing.
|
||||
assert!(
|
||||
!output.contains("| Kit|Pro |"),
|
||||
"unescaped pipe in agent name must not appear as a cell boundary"
|
||||
);
|
||||
|
||||
// The row must start and end with `|` and the escaped name and address must appear.
|
||||
let kit_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Kit\\|Pro"))
|
||||
.expect("Kit\\|Pro row must be present");
|
||||
assert!(kit_row.starts_with('|'), "row must start with |");
|
||||
assert!(kit_row.ends_with('|'), "row must end with |");
|
||||
assert!(
|
||||
kit_row.contains("@Kit\\|Pro"),
|
||||
"address cell must use escaped name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_newline_in_persona_name() {
|
||||
let personas = vec![make_persona("p1", "Builder\nExpert")];
|
||||
let agents = vec![make_agent("Scout", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
|
||||
assert!(
|
||||
output.contains("Builder Expert"),
|
||||
"newline in persona display_name must be replaced with a space"
|
||||
);
|
||||
|
||||
// The table row for Scout must be a single line (no embedded newline).
|
||||
let scout_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Scout"))
|
||||
.expect("Scout row must be present");
|
||||
assert!(
|
||||
scout_row.contains("Builder Expert"),
|
||||
"persona name with newline replaced by space must appear on the Scout row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nexisting section\n<!-- END BUZZ MANAGED -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "same content").unwrap();
|
||||
let after_first = fs::read_to_string(&file).unwrap();
|
||||
|
||||
upsert_managed_section(&file, "same content").unwrap();
|
||||
let after_second = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
after_first, after_second,
|
||||
"upsert must be idempotent: second call must not alter the file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_agents_md_writes_version_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn relay_ws_url() -> String {
|
||||
|
||||
/// Read the workspace relay URL override, if set. Returns `None` when no
|
||||
/// override is active or when the mutex is poisoned (best-effort).
|
||||
fn workspace_relay_override(state: &AppState) -> Option<String> {
|
||||
pub(crate) fn workspace_relay_override(state: &AppState) -> Option<String> {
|
||||
state
|
||||
.relay_url_override
|
||||
.lock()
|
||||
|
||||
Reference in New Issue
Block a user