mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
Implements the Rust/backend half of 30178 team catalog sharing on the community catalog. No TS callers yet — this is PR 1 of 2; [#3995](https://github.com/block/buzz/pull/3995) (stacked here) adds the parse layer, hooks, `CommunityCatalogDialog`, and e2e tests. ## What this adds **Projection builder** (`team_catalog.rs`): `build_team_catalog_event/content` produces a 30178 event from a team + member definitions. Size contracts: 192 KiB total ceiling, per-field bounds (name 256 B, text 4 KiB, system-prompt 16 KiB, avatar URL 32 KiB for projection fields; https URLs additionally validated at 2,048 bytes). Avatar handling: oversized raster data URLs downscaled to fit; oversized built-in avatars silently omitted; oversized https URLs rejected with a named error. **Share/unshare/tombstone** (`commands/teams/pending.rs`): `prepare_team_publication_at` retains a signed 30178 head for the flush loop; share state is relay-scoped (one community can share while another does not). `refresh_or_retract_shared_head_at` rebuilds or tombstones the head immediately on team/member edit — no stale publication until the next boot. Both the 30178 catalog and 30176 team tombstones are signed with a `created_at` that strictly dominates the retained head's (read inside the delete transaction), so a future-dated head cannot survive its own deletion under the relay's `created_at <=` soft-delete gate. **Serialized-publisher share command** (`commands/teams/sharing.rs`, `managed_agents/persona_events.rs`): `set_team_shared` publishes the retained head through `flush_pending_events_at` rather than submitting the prepared event directly. A direct submit ran outside `managed_agents_store_lock` and raced `delete_team`: the delete atomically purges the head's retained row and enqueues a newer 30178 tombstone in one transaction, and a delayed direct submit could land the old shared head *after* that tombstone. Because 30178 replacement has no deletion watermark, the deleted team would go publicly live again. Routing through the flush is necessary but not sufficient, because the flush is not itself a single publisher: several call sites (the 30s sweep, this share toggle, managed-policy updates) invoke it concurrently, and each invocation has an await gap between its per-row re-read and its relay POST. A second flush could publish the tombstone in that gap while an earlier flush's delayed POST lands the just-purged head after it. The flush now acquires a per-scope publisher mutex — keyed by the canonical retention `db_path`, which already *is* the durable scope identity (hashed normalized relay URL + owner pubkey) — and holds it across its entire invocation: snapshot, per-row re-read, POST, and `mark_synced`. Serialized-per-scope flush ⟹ within one scope the only interleavings are head-before-tombstone (the head lands first, then is dominated by the later tombstone) or purged-row-skip (the delete committed first, so the re-read skips the head) — a purged head can never publish after its tombstone. The lock is a `LazyLock<Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>>` static rather than an `AppState` field: it keeps the invariant at its acquisition site and out of the size-ratcheted `app_state.rs` (precedent: `agent_models_databricks.rs`'s `AUTH_GATE`), and the std-mutex map guard is released before the async guard is awaited so it never spans an await point. Keying per scope — rather than one process-global lock — means a stalled or hostile relay in one community can no longer block publication in every other community, and each per-row submit is additionally wrapped in a `tokio::time::timeout(PUBLISH_TIMEOUT = 60s)`: `submit_signed_event_at_with_keys` first waits on the process-wide admission gate (up to 300s on a 429) and then POSTs on the app-wide `http_client`, which leaves reqwest's connect/read/total timeouts unset, so a relay that accepts the connection and never finishes the response would otherwise pin the lock forever. A timeout takes the same `Err`/`continue` path as a relay rejection — the row stays pending for the next 30s sweep and a timed-out tombstone keeps its replacement deferred this pass — so a live admission gate now surfaces as timeout-pending rather than a held lock, the correct durable behavior since the sweep retries. `publicationStatus` (Published/Queued) is derived by re-reading the retained row's pending flag after the flush. The previously-unused `relayMessage` field is dropped from `SetTeamSharedResult`: the flush loop swallows every per-event relay rejection to its own log and only surfaces local DB faults (which the status re-read already propagates), so the field was permanently `null` on this path. #3995's `tauriTeams.ts` mapping and `useTeamActions.ts` log branch drop with it. **Domination-aware flush** (`managed_agents/persona_events.rs`): a strictly-dominating tombstone can be signed past the relay's ingest acceptance window (`MAX_TIMESTAMP_DRIFT_SECS`, ±900s from server time). Republishing such a byte-frozen event verbatim from the pending queue lets it age out of the window and be rejected forever, stranding the head live. The flush loop is now domination-aware for every retained kind:5 (covering both 30176 and 30178). For a pending tombstone with floor `f` (= the retained row's own `created_at`): - `f <= now` → re-date and re-sign at `now` (mirrors the existing archive-request re-sign branch; `mark_synced` stays keyed to the untouched retained row, so a re-date can't mask a concurrent edit), - `now < f <= now + 900` → publish verbatim at `f`, inside the window, - `f > now + 900` → skip the sweep; the event stays pending and its replacement keeps deferring (via `failed_tombstones`) so out-of-order retraction remains impossible, converging as the wall clock advances. No path emits an event the gate rejects, and a boundary reject self-heals through the submit-error requeue. Durable across offline gaps of any length. **Atomic adopt** (`commands/teams/adopt/`): `add_team_from_catalog` re-fetches and signature-verifies the head from the relay, then plans and commits a multi-entity add across two store writes with byte-exact rollback on any failure (crash window between writes explicitly retained). `plan_add` resolves full member provenance (owner, d-tag, member-key, projection-hash), reuses a recipient's own local built-in only when the published slug matches and the reuse hint's `projection_hash` — recomputed from the member's own embedded fields at the parse boundary and rejected on mismatch — equals (case-insensitively, matching the boundary's hex tolerance) the recipient's local built-in hash, and reactivates deactivated copies on re-add. Because the boundary already proves the hint hash describes the reviewed projection, a publisher cannot pair a real built-in's slug + hash with arbitrary reviewed fields to make adoption install the recipient's built-in in place of what was shown. `commit_stores` snapshots both stores before writing and restores them on failure. The commit and the retention enqueue are sequenced inside `commit_and_enqueue`, the sole route to a durable adoption commit: once the store write succeeds it enqueues a pending 30175 for every member copy the add wrote or reactivated and a pending 30176 for the team, so a crash before the next boot reconcile cannot lose the only adopted copy. A provenance match on an already-active copy is retained too (not just a reactivation): a recovery retry after a crash between the persona write and post-commit retention finds the copy active with no 30175 row, and `plan_add` short-circuits once the team row exists, so this reuse branch is the only place that retry can re-enqueue the orphaned member head. Retaining unconditionally is conservative, not exact — an active copy still referenced by a standalone managed agent can already hold a live head, and re-retaining only bumps it monotonically; reused built-ins are handled separately and never reach this branch. A byte-identical reused built-in and an idempotent replay write nothing and enqueue nothing; a failed commit propagates and enqueues nothing. Enqueue is best-effort per row (the boot reconcile is the backstop). The frontend refreshes via the `useAddTeamFromCatalogMutation` query invalidation in #3995, so no `agents-data-changed` emit is needed here. **Startup reconcile** (`event_sync.rs`): `reconcile_team_catalog_heads_at` walks all retained 30178 heads at boot: republishes heads whose content changed, tombstones heads whose team or member was deleted, skips unshared heads and unchanged content. Multi-team continuation — all shared teams processed in one pass. **Cross-device catalog retention** (`commands/personas/inbound.rs`): both recovery paths above — the boot reconcile worklist and the interactive `refresh_or_retract_shared_head_at` — key off a retained 30178 row and guard-return without one. A second device therefore never retained the owner's own catalog head published from another device, so its later edit or delete could never supersede or retract that discoverable head. The inbound reconcile now retains an inbound 30178 head as this device's publication witness through `retain_inbound_catalog_witness`, a self-gating dispatcher invoked unconditionally on the production non-deletion path: newest-wins via `retain_inbound_event`, arrival-scoped, no local JSON store, and deliberately **no** refresh or republish on arrival — a 30178 arrival is either this device's own echo or the other device's publication, and rebuilding on either would make two devices ping-pong identical heads. Retention advances the witness and stops. The tombstone router accepts a kind:5 covering a 30178 coordinate, so an inbound deletion purges the retained head on the receiving device (the covered-head purge already happens inside `commit_inbound_tombstone_with_store`; a 30178 head has no local record to remove). After a successful inbound persona/team upsert, this device refreshes the affected shared heads so the community catalog tracks the inbound edit (persona edit → every team whose resolved members include it, resolving the local persona `id` by d-tag; team edit → that team's head); after an inbound tombstone, a team deletion retracts its 30178 coordinate and a persona deletion refreshes the teams that listed it. The refresh is idempotent across devices: `refresh_or_retract_shared_head_at` skips the publish when the rebuilt projection is byte-identical to the retained head and still shared, so the editing device's own published head triggers no churn republish on the receiving device. **Executable-text concealment gate** (`team_catalog.rs`, `definition_validation.rs`): `validate_team_catalog_content` — the single chokepoint both the publish builder (`build_team_catalog_content`) and the adopt parser (`team_catalog_content_from_event`) funnel through — now rejects invisible, default-ignorable, and bidirectional-override characters (e.g. U+200B, U+2066, U+202E) in every field delivered verbatim to the ACP harness or rendered as reviewed identity in the catalog UI. This is the same invariant the persona catalog already enforces at its own parse boundary (`persona_catalog::parse_agent`); the 30178 boundary was the outlier. Member `display_name` + `system_prompt` go through `validate_agent_definition_text` per member (exact parity with `parse_agent`: display-name rule with no layout controls, prompt rule allowing `\n`/`\t`); `name_pool` entries take the display-name rule since they are minted verbatim as instance display names; team `instructions` take the visible-text rule with layout controls allowed, since they reach `BUZZ_ACP_TEAM_INSTRUCTIONS` multiline. The team `name` takes the display-name rule (no layout controls) and the `description` takes the visible-text rule with layout controls allowed, since both are rendered verbatim in the catalog UI as reviewed identity. `validate_visible_text` is exposed `pub(crate)` from `definition_validation.rs` and re-exported via `managed_agents`. A signed, shared, current head can no longer smuggle concealed control characters into executable configuration or reviewed catalog text through either the publish or the adopt path; emoji (VS16/ZWJ) names and multiline instructions still pass. **Types**: `TeamRecord` and `AgentDefinition` extended with `shared`, `catalog_source`, `team_catalog_source` fields. All commands registered in `lib.rs`. ## Tests - `team_catalog/tests.rs`: projection, size contracts, member-key stability, tombstone rollback, fixture matrix - `adopt/tests.rs`: head verification, store planning, provenance, rollback - `team_catalog/tests/concealment.rs`: the chokepoint rejects default-ignorable (U+200B) and bidi controls (U+2066, U+202E) in member `display_name`, `system_prompt`, `name_pool`, team `instructions`, and the team `name`/`description`, on both the publish and adopt paths; an emoji-bearing display name and multiline instructions still pass, so no legitimate team becomes unshareable - `team_catalog/tests/reuse_hint.rs`: a member pairing a real built-in's slug with that built-in's genuine `projection_hash` but carrying unrelated reviewed fields is rejected at the parse boundary, so adoption can never substitute the recipient's built-in for the reviewed projection; an honestly-stamped built-in reuse hint (including an uppercase form of its true hash) still passes the boundary. Removing the boundary recompute lets the tampered member validate, proving the test discriminates the substitution class - `adopt/tests/reuse.rs`: `reusable_builtin` reuses a local built-in for an exact-match hint (one record, no copy), reuses it just the same when the genuine hash is uppercased (case-insensitive, matching the boundary — one record, not two), and falls through to an authoritative embedded copy when the hash does not match. Comparing the hash case-sensitively turns the uppercase case red (two records instead of one reused) - `adopt/tests/concealment.rs`: a signed, shared, current head carrying a bidi override drives the `add_verified_team` sequence (verify+parse → `plan_add` → `commit_and_enqueue`) through real temp stores and a real retention scope, asserting the head is rejected AND the personas store, teams store, and retention rows are all left byte-unchanged. Stripping the concealment call at the chokepoint turns it red — the parse then succeeds and both stores are written, proving the test discriminates a validate-after-write regression, not just an error return - `adopt/tests/retention.rs`: adoption drives `commit_and_enqueue` through a spy commit + a real temp-dir retention scope and asserts persisted pending rows — commits-then-enqueues (30175 per minted member + 30176 team), a failed commit enqueues nothing, an idempotent replay skips both the commit and the enqueue, a reused built-in retains only the team, a reactivated copy is re-retained, and a partial-commit crash recovery (active member copy with no retention row, team row absent) re-enqueues the orphaned member's 30175. Deleting the enqueue inside the seam turns these red — the wiring, not just the helper, is protected - `sharing/tests.rs`: publish/queue lifecycle plus three concurrency gate tests. (a) `concurrent_flushes_never_land_the_head_after_its_tombstone` prepares a share, runs a concurrent delete's purge+tombstone, flushes the tombstone to a recording relay, then releases the delayed share and asserts the purged 30178 head is never published after its tombstone and no pending row survives (removing the lock turns it red — the relay sees the resurrected head after the tombstone). (b) `a_stalled_scope_does_not_block_publication_in_another_scope` pins one scope's flush mid-POST on a stalled relay and runs a second scope's flush to completion, asserting it publishes without waiting (re-globalizing the key turns it red). (c) `a_stalled_relay_releases_the_publisher_lock_within_the_bound` proves a never-completing POST returns within `PUBLISH_TIMEOUT`, leaves the row pending, and releases the lock so a subsequent same-scope flush proceeds (removing the timeout turns it red). `test_relay_rejection_stays_durably_queued` asserts queued + still-pending rather than a relay-message string, matching the flush-routed contract where the rejection text is no longer surfaced - `pending/tests.rs`: share/unshare/tombstone lifecycle, edit refresh/retract, tombstone timestamp domination, typed outcomes, cross-device catalog convergence — device B retains device A's inbound head then supersedes it on a member edit and tombstones the coordinate on a delete, a byte-identical rebuild is a no-op (`Noop`, `created_at` untouched), and an inbound retention alone queues no outbound publish (the no-ping-pong guard). Neutralizing the inbound retention leg turns the supersede/tombstone regressions red — B stays blind (`Noop`, no dominating tombstone) — proving they discriminate the load-bearing leg - `catalog_reconcile_tests.rs`: a signed kind:30178 head is driven through the real production entrypoint `reconcile_inbound_persona_event_blocking` over a `MockRuntime` `AppHandle` (retention scope resolved from the handle's `app_data_dir` under an overridden `$HOME`/`$XDG_DATA_HOME`), asserting the arrival witness is retained at the owner coordinate with `pending_sync=false`, stored verbatim, and no outbound publish is queued. An early return for `KIND_TEAM_CATALOG` immediately before the production `retain_inbound_catalog_witness` invocation turns it red, proving the seam under test is the production dispatch path and not a test-only shim - `pending/tests/gate.rs`: flush driven through a stub relay that logs every `POST /events` with its accept/reject status and enforces the real ±900s ingest gate, for both 30176 and 30178 — within-window publish+dominate; beyond-window stays-pending with **zero POSTs** (the gate never receives a rejectable event); and the delayed/offline-retry case where a tombstone signed strictly past a then-future head has aged more than 900s into the past, so flush must re-date to `now` to publish. The reversal check — restore the byte-frozen replay in `persona_events.rs` and the delayed-retry and zero-POST assertions go red — is what proves the suite discriminates the fix from the rejected implementation - `teams/tests.rs`: 30176 tombstone timestamp domination and no-head fallback - `event_sync_team_catalog_tests.rs`: reconcile scenarios including multi-head continuation - 26 shared JSON parity fixtures (`tests/fixtures/team_catalog_content/`) consumed by both this PR's Rust tests and #3995's TS tests ## Durable ordering, deletion reconcile, and relay-contract alignment The catalog paths above sit on the shared inbound/deletion retention seam. This PR makes that seam's ordering structural rather than conventional, adds a negative-side (deletion) counterpart to the existing positive-side boot backstop, and aligns inbound resolution with the relay's actual soft-delete and NIP-33 winner rules. **Preflight-then-commit (`commit_inbound_with_store`, `commands/personas/inbound.rs` + `retention.rs`).** A named primitive runs the fallible store mutation first and advances the durable retention head only on success; an event that loses preflight returns `Skipped` without touching the store. The persona/team upsert arms and the inbound kind:5 removal path all route through it, so no inbound arm can advance the head ahead of the store write it represents. The managed-agent arm keeps its own preflight (its runtime transition must not run for a skipped event) and still advances the head only after `save_managed_agents`. **Atomic + monotonic tombstone helpers (30175 / 30176 / 30177).** The three ordinary tombstone helpers (`commands/personas/pending.rs`, `commands/agents_pending.rs`, `commands/teams/mod.rs`) each read the prior head, sign, delete, and retain inside one `BEGIN IMMEDIATE` transaction, and sign the kind:5 with a `created_at` that strictly dominates the prior head (`monotonic_created_at(prior_head)`). This mirrors the 30178 `tombstone_team_catalog_coordinate` precedent. The three siblings deliberately duplicate the `BEGIN IMMEDIATE` shape rather than sharing a helper this round — the shared-helper consolidation is deferred to the persona-tombstone follow-up PR where the flush-replay class already lives. **Deletion reconcile (`event_sync.rs`, negative-side counterpart of the positive-side boot backstop).** The positive side already reconstructs missing retained heads at boot from surviving disk records. Deletion is the asymmetric gap: an atomic tombstone helper preserves the head on failure, but boot reconcile enumerated disk records — deletion already removed them — so an orphan retained head was never tombstoned. The deletion reconcile enumerates retained 30175/30176 heads and tombstones only genuine orphans (a retained head with no matching disk record), routing through the now-atomic helpers. It reconciles only against a successfully parsed store: a truncated, malformed, or wrong-shape JSON store fails loudly (never triggers a tombstone); a missing file is treated as empty. Managed agents (30177) are excluded by design: their inbound sync retains a head *without* minting a local disk record (agents carry device-local secrets that can't come from a relay event), so a retained 30177 head with no matching record is the normal cross-device state for every agent created on another device — not a lost deletion. Sweeping it would tombstone and archive another device's live agents at boot. Agent deletion-retry therefore stays a pre-existing gap owned by the direct delete path (tracked in Follow-ups). **Inbound relay-contract alignment (`retention.rs`, `commands/personas/inbound.rs`).** Two inbound rules now match the relay: - *Equal-second tie-break.* `inbound_event_outcome` previously treated every equal timestamp as stale; the relay resolves equal `created_at` by lowest event id. Preflight now matches the relay tuple — strictly newer timestamp wins, equal timestamp resolves by lower event id, an exact echo skips — so two devices authoring different same-second successors converge instead of one silently republishing an event the relay refuses. - *Covered-head resolution on inbound kind:5.* `reconcile_inbound_tombstone` previously consulted only the retained kind:5 row, so a historical tombstone replayed after a newer recreation deleted the recreated record and never purged the covered head. Preflight now resolves both the tombstone row and the covered `(target_kind, owner, d_tag)` head: a target head strictly newer than the tombstone preserves JSON and skips; an actually-covered head is removed from disk first (fallible), then the tombstone row commit and covered-head purge happen atomically. A JSON-save failure advances neither, so the identical event stays retryable. **Atomic agent-archive coupling (`commands/agents_pending.rs`).** The 30177 tombstone and the 9035 archive request now enqueue in one `BEGIN IMMEDIATE` transaction, with the archive's `persona_id` derived from the retained head (`persona_id_from_head`) rather than the deleted record, where it survives the tombstone as owner-signed historical alias data. The standalone `archive_managed_agent_pending` production callers are removed — there is no double-enqueue path. Unlike personas/teams, this coupling is not re-enqueued by the boot deletion reconcile (30177 is excluded, above): a crash after the disk-authoritative record is removed but before this transaction commits leaves agent deletion-retry a pre-existing gap owned by this direct delete path (tracked in Follow-ups). ## Follow-ups `publish_prepared_persona` (`commands/personas/sharing.rs`) has the identical direct-submit-outside-the-lock race for 30175 persona heads that this PR fixes for 30178 team heads. It predates this work and is tracked in the separate persona-tombstone follow-up PR, not folded here. The app-wide `http_client` leaves reqwest's connect/read/total timeouts unset (`app_state.rs` builder configures only pool options). This PR bounds the team-publish call site with a `tokio::time::timeout`, but every other consumer of the shared client remains exposed to a non-responding endpoint. A client-wide default timeout is the broader fix; it is pre-existing on main and affects all consumers, so it is out of scope here. Managed-agent deletion-retry: the atomic 30177 tombstone + 9035 archive is best-effort, and unlike personas/teams it is deliberately excluded from the boot deletion reconcile (a device-local-absent 30177 head is the normal cross-device state, not a deletion). A crash between the delete's store write and its retention enqueue therefore has no boot backstop for agents. The durable fix is a local deletion intent written before the record is removed; deferred rather than folded into this round. Stack: this PR → [#3995](https://github.com/block/buzz/pull/3995) Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
798 lines
32 KiB
Rust
798 lines
32 KiB
Rust
//! Boot-time disk→relay event reconcile ("event sync").
|
|
//!
|
|
//! Reconciles the on-disk JSON stores (`personas.json`, `teams.json`,
|
|
//! `managed-agents.json`) into signed retention events queued for relay
|
|
//! publish. Runs after identity resolution (event signing needs the owner
|
|
//! keys), unlike the pre-identity migrations in [`crate::migration`].
|
|
|
|
use std::path::Path;
|
|
|
|
/// Reconcile personas, teams, and managed agents into signed retention
|
|
/// events. All readers consume the already-synced
|
|
/// `personas.json`/`teams.json`/`managed-agents.json` that
|
|
/// `sync_team_personas` wrote in [`crate::migration::run_boot_migrations`]
|
|
/// (see its `# Ordering` guard). Event signing needs the resolved owner keys,
|
|
/// so this runs after identity resolution, not in the boot migrations.
|
|
pub fn run_event_sync(
|
|
app: &tauri::AppHandle,
|
|
owner_keys: &nostr::Keys,
|
|
db_path: &Path,
|
|
) -> Result<(), String> {
|
|
// Persona and agent legs stay best-effort: they log and swallow, and their
|
|
// failure does not undo the boot team-membership repair. The team leg is
|
|
// fatal — it establishes the superseding local head (a monotonic
|
|
// `created_at`) that lets `retain_inbound_event`'s equal/older guard reject
|
|
// a stale relay roster. If it fails, the caller must not let the frontend
|
|
// expose the community and start inbound replay against an un-superseded
|
|
// disk state.
|
|
migrate_personas_to_events(app, owner_keys, db_path);
|
|
migrate_teams_to_events(app, owner_keys, db_path)?;
|
|
reconcile_team_catalog_heads(app, owner_keys, db_path);
|
|
crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys, db_path);
|
|
// Negative-side backstop: retract any retained head whose disk record is
|
|
// gone (a deletion whose atomic tombstone failed after removing the JSON).
|
|
// Runs LAST so the positive legs' just-retained live heads are matched and
|
|
// skipped; only genuine orphans remain.
|
|
reconcile_deleted_heads(app, owner_keys, db_path);
|
|
Ok(())
|
|
}
|
|
|
|
/// Run the scoped event reconcile to completion on the blocking pool.
|
|
///
|
|
/// Callers that must not let downstream work observe a not-yet-retained disk
|
|
/// state (e.g. `apply_workspace` before the frontend can start inbound history
|
|
/// replay) await this so the repaired local heads are durably retained — with a
|
|
/// superseding `monotonic_created_at` — before an old relay head can race in.
|
|
/// The owner keys are moved in so the task never touches the `AppState::keys`
|
|
/// mutex; the reconcile itself is synchronous JSON/SQLite/signing work, so it
|
|
/// runs on the blocking pool rather than an async worker.
|
|
///
|
|
/// Returns `Err` if the task fails to join or the fatal team leg errors, so the
|
|
/// caller can withhold community exposure until the superseding head is durable.
|
|
pub async fn run_event_sync_blocking(
|
|
app: tauri::AppHandle,
|
|
owner_keys: nostr::Keys,
|
|
db_path: std::path::PathBuf,
|
|
) -> Result<(), String> {
|
|
tauri::async_runtime::spawn_blocking(move || run_event_sync(&app, &owner_keys, &db_path))
|
|
.await
|
|
.map_err(|e| format!("event-sync: spawn_blocking failed: {e}"))?
|
|
}
|
|
|
|
/// Reconcile `personas.json` into the persona-event retention store.
|
|
///
|
|
/// Must run AFTER `fold_personas_into_agent_store` and
|
|
/// `detach_directory_backed_teams` (depends on field renames and store
|
|
/// unification being complete) and AFTER the persisted identity is resolved
|
|
/// (it signs every retained event with the owner's keys).
|
|
///
|
|
/// Per-record reconcile: for each non-builtin persona it compares the freshly
|
|
/// serialized event content against the retained row at the same coordinate
|
|
/// and re-retains (marking `pending_sync = 1`) only when the row is absent or
|
|
/// its content differs. An unchanged persona is left untouched, so a launch
|
|
/// after a no-op edit does not churn `pending_sync`; a persona added or edited
|
|
/// on disk between launches is picked up and republished. There is no
|
|
/// whole-store sentinel — comparing per coordinate is what lets newly added
|
|
/// personas reach the relay.
|
|
///
|
|
/// Strategy: write to local SQLite retention first (durable copy), mark as
|
|
/// `pending_sync = 1` for later relay publish. Migration succeeds on local
|
|
/// write, not relay acknowledgment. Every retained row is a real signed
|
|
/// event — there is no placeholder path.
|
|
pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) {
|
|
use crate::managed_agents::managed_agents_base_dir;
|
|
|
|
let Ok(base_dir) = managed_agents_base_dir(app) else {
|
|
return;
|
|
};
|
|
|
|
match migrate_personas_in_dir_at(&base_dir, keys, db_path) {
|
|
Ok(0) => {}
|
|
Ok(migrated) => {
|
|
eprintln!(
|
|
"buzz-desktop: persona-event-migration: {migrated} personas migrated to retention"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("buzz-desktop: persona-event-migration: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing.
|
|
///
|
|
/// Returns the number of personas (re)written to the retention store. Returns
|
|
/// `Ok(0)` when every non-builtin persona already has a matching retained row
|
|
/// (or there are none to reconcile).
|
|
#[cfg(test)]
|
|
fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, String> {
|
|
migrate_personas_in_dir_at(base_dir, keys, &base_dir.join("retention.db"))
|
|
}
|
|
|
|
fn migrate_personas_in_dir_at(
|
|
base_dir: &Path,
|
|
keys: &nostr::Keys,
|
|
db_path: &Path,
|
|
) -> Result<u32, String> {
|
|
use crate::managed_agents::{
|
|
persona_events::{build_persona_event, monotonic_created_at, persona_d_tag},
|
|
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
|
|
};
|
|
use buzz_core_pkg::kind::KIND_PERSONA;
|
|
use nostr::JsonUtil;
|
|
|
|
let pubkey = keys.public_key().to_hex();
|
|
|
|
// Post-fold (Phase 1A.2): definitions live as key-less records in the
|
|
// unified agent store, presented in the legacy shape. Pre-fold boots
|
|
// (run_event_sync runs after run_boot_migrations, so the fold has
|
|
// already happened) never reach this path with personas.json present —
|
|
// but read it as a fallback for one release in case the fold errored.
|
|
let records = read_persona_definitions(base_dir)?;
|
|
|
|
if records.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
// Open (or create) the retention database.
|
|
let conn =
|
|
open_retention_db(db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
|
|
|
|
let mut migrated = 0u32;
|
|
|
|
for record in &records {
|
|
// Skip built-in personas — they're always available from code.
|
|
if record.is_builtin {
|
|
continue;
|
|
}
|
|
|
|
let d_tag = persona_d_tag(record);
|
|
|
|
// Fetch the retained head first so the rebuilt event can supersede it:
|
|
// build at the default `now` and a future-dated head (clock skew, or an
|
|
// interactive same-second `max(now, head+1)` bump) would make
|
|
// `retain_event`'s `created_at >= ...` guard SILENTLY skip the UPDATE
|
|
// while `migrated` over-reports. Mirror the interactive sites' monotonic
|
|
// bump (F1) so a changed body always lands.
|
|
let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?;
|
|
|
|
let mut scoped_record = record.clone();
|
|
scoped_record.shared = existing
|
|
.as_ref()
|
|
.and_then(|row| nostr::Event::from_json(&row.raw_event).ok())
|
|
.is_some_and(|event| buzz_core_pkg::kind::event_is_shared(&event));
|
|
let event = build_persona_event(&scoped_record)
|
|
.map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))?
|
|
.custom_created_at(monotonic_created_at(
|
|
existing.as_ref().map(|row| row.created_at),
|
|
))
|
|
.sign_with_keys(keys)
|
|
.map_err(|e| format!("failed to sign event for '{}': {e}", record.display_name))?;
|
|
|
|
// Per-coordinate reconcile: skip when an identical body is already
|
|
// retained, so an unchanged persona doesn't reset `pending_sync`.
|
|
// Content is timestamp-independent, so the monotonic bump above never
|
|
// forces a spurious republish.
|
|
let event_content = event.content.to_string();
|
|
if existing
|
|
.as_ref()
|
|
.is_some_and(|row| row.content == event_content)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let retained = RetainedEvent {
|
|
kind: KIND_PERSONA,
|
|
pubkey: pubkey.clone(),
|
|
d_tag,
|
|
content: event_content,
|
|
// Safety: nostr timestamps are seconds and stay below i64::MAX
|
|
// until year 2262.
|
|
created_at: event.created_at.as_secs() as i64,
|
|
raw_event: event.as_json(),
|
|
pending_sync: true,
|
|
};
|
|
|
|
// The monotonic bump guarantees `created_at > head`, so the upsert's
|
|
// `>=` guard always lands the UPDATE — `migrated` counts only real,
|
|
// retained republishes.
|
|
retain_event(&conn, &retained)
|
|
.map_err(|e| format!("failed to retain '{}': {e}", record.display_name))?;
|
|
migrated += 1;
|
|
}
|
|
|
|
Ok(migrated)
|
|
}
|
|
|
|
/// Reconcile `teams.json` into kind:30176 team events in the retention store.
|
|
///
|
|
/// Mirrors [`migrate_personas_to_events`] for teams: it picks up team metadata
|
|
/// edits (name/description/persona_ids) made on disk between launches and
|
|
/// queues them for relay publish. Managed agents (kind:30177) are deliberately
|
|
/// NOT reconciled here — they have no pack/dir source and are backfilled from
|
|
/// `managed-agents.json` elsewhere.
|
|
///
|
|
/// Must run after the persisted identity is resolved (it signs each event with
|
|
/// the owner's keys).
|
|
pub fn migrate_teams_to_events(
|
|
app: &tauri::AppHandle,
|
|
keys: &nostr::Keys,
|
|
db_path: &Path,
|
|
) -> Result<(), String> {
|
|
use crate::managed_agents::managed_agents_base_dir;
|
|
|
|
let base_dir = managed_agents_base_dir(app)
|
|
.map_err(|e| format!("team-event-migration: base dir unavailable: {e}"))?;
|
|
|
|
match migrate_teams_in_dir_at(&base_dir, keys, db_path) {
|
|
Ok(0) => Ok(()),
|
|
Ok(migrated) => {
|
|
eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention");
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(format!("team-event-migration: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Core team reconcile logic, decoupled from the Tauri `AppHandle` for testing.
|
|
///
|
|
/// Returns the number of teams (re)written to the retention store. The
|
|
/// per-coordinate content compare matches [`migrate_personas_in_dir`]: an
|
|
/// unchanged team is skipped so a launch does not churn `pending_sync`.
|
|
#[cfg(test)]
|
|
fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, String> {
|
|
migrate_teams_in_dir_at(base_dir, keys, &base_dir.join("retention.db"))
|
|
}
|
|
|
|
fn migrate_teams_in_dir_at(
|
|
base_dir: &Path,
|
|
keys: &nostr::Keys,
|
|
db_path: &Path,
|
|
) -> Result<u32, String> {
|
|
use crate::managed_agents::{
|
|
persona_events::monotonic_created_at,
|
|
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
|
|
team_events::build_team_event,
|
|
TeamRecord,
|
|
};
|
|
use buzz_core_pkg::kind::KIND_TEAM;
|
|
use nostr::JsonUtil;
|
|
|
|
let pubkey = keys.public_key().to_hex();
|
|
|
|
let teams_path = base_dir.join("teams.json");
|
|
if !teams_path.exists() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&teams_path)
|
|
.map_err(|e| format!("failed to read teams.json: {e}"))?;
|
|
|
|
let records: Vec<TeamRecord> =
|
|
serde_json::from_str(&content).map_err(|e| format!("failed to parse teams.json: {e}"))?;
|
|
|
|
if records.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let conn =
|
|
open_retention_db(db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
|
|
|
|
let mut migrated = 0u32;
|
|
|
|
for record in &records {
|
|
// Skip built-in teams — they're always available from code.
|
|
if record.is_builtin {
|
|
continue;
|
|
}
|
|
|
|
// Team d-tag is the team id (team_events.rs: no slug fallback).
|
|
let d_tag = record.id.clone();
|
|
|
|
// Fetch the head first so the monotonic bump can supersede a
|
|
// future-dated head — see migrate_personas_in_dir (F1/F8).
|
|
let existing = get_retained_event(&conn, KIND_TEAM, &pubkey, &d_tag)?;
|
|
|
|
let event = build_team_event(record)
|
|
.map_err(|e| format!("failed to build event for team '{}': {e}", record.name))?
|
|
.custom_created_at(monotonic_created_at(
|
|
existing.as_ref().map(|row| row.created_at),
|
|
))
|
|
.sign_with_keys(keys)
|
|
.map_err(|e| format!("failed to sign event for team '{}': {e}", record.name))?;
|
|
|
|
let event_content = event.content.to_string();
|
|
if existing
|
|
.as_ref()
|
|
.is_some_and(|row| row.content == event_content)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let retained = RetainedEvent {
|
|
kind: KIND_TEAM,
|
|
pubkey: pubkey.clone(),
|
|
d_tag,
|
|
content: event_content,
|
|
created_at: event.created_at.as_secs() as i64,
|
|
raw_event: event.as_json(),
|
|
pending_sync: true,
|
|
};
|
|
|
|
// Monotonic bump guarantees the upsert UPDATE lands — `migrated` counts
|
|
// only real republishes.
|
|
retain_event(&conn, &retained)
|
|
.map_err(|e| format!("failed to retain team '{}': {e}", record.name))?;
|
|
migrated += 1;
|
|
}
|
|
|
|
Ok(migrated)
|
|
}
|
|
|
|
/// Reconcile every shared team's kind:30178 catalog head against the team as
|
|
/// it exists on disk now.
|
|
///
|
|
/// The publish path rebuilds a catalog head only when the owner touches the
|
|
/// team itself. A team's *members* are separate records, so editing or
|
|
/// deleting one changes what the team is while leaving a stale projection
|
|
/// published. This seam catches that drift, over currently-shared heads only —
|
|
/// an unshared head is not discoverable, so nothing is stale to correct.
|
|
///
|
|
/// Two outcomes, both keeping the published catalog truthful:
|
|
///
|
|
/// - Still projects, bytes changed → republish a newer shared head.
|
|
/// - Can no longer be projected (a member was deleted, or it outgrew the size
|
|
/// contract) → **purge + tombstone** (I4). An unshared stale body is not a
|
|
/// true retraction — it leaves the coordinate live with no opt-in tag, so
|
|
/// the team must fully disappear. A typed `team-catalog-auto-retracted`
|
|
/// notice names the team and reason so the owner knows why the toggle
|
|
/// changed.
|
|
///
|
|
/// Deliberately not wired into `save_teams()`: that disk-store primitive has
|
|
/// many callers (import, repair, cascade delete), and signing a relay event
|
|
/// inside it would publish on paths that never intended to.
|
|
fn reconcile_team_catalog_heads(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) {
|
|
use crate::managed_agents::managed_agents_base_dir;
|
|
|
|
let Ok(base_dir) = managed_agents_base_dir(app) else {
|
|
return;
|
|
};
|
|
|
|
match reconcile_team_catalog_heads_at(app, &base_dir, keys, db_path) {
|
|
Ok(0) => {}
|
|
Ok(reconciled) => {
|
|
eprintln!(
|
|
"buzz-desktop: team-catalog-reconcile: {reconciled} shared team heads refreshed"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("buzz-desktop: team-catalog-reconcile: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Core catalog reconcile, decoupled from the Tauri `AppHandle` for testing.
|
|
///
|
|
/// Returns the number of heads (re)written — republished or tombstoned.
|
|
fn reconcile_team_catalog_heads_at(
|
|
app: &tauri::AppHandle,
|
|
base_dir: &Path,
|
|
keys: &nostr::Keys,
|
|
db_path: &Path,
|
|
) -> Result<u32, String> {
|
|
reconcile_team_catalog_heads_core(Some(app), base_dir, keys, db_path)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn reconcile_team_catalog_heads_at_for_test(
|
|
base_dir: &Path,
|
|
keys: &nostr::Keys,
|
|
db_path: &Path,
|
|
) -> Result<u32, String> {
|
|
reconcile_team_catalog_heads_core(None, base_dir, keys, db_path)
|
|
}
|
|
|
|
/// Inner reconcile, `app` is `None` only in unit tests (no Tauri runtime).
|
|
fn reconcile_team_catalog_heads_core(
|
|
app: Option<&tauri::AppHandle>,
|
|
base_dir: &Path,
|
|
keys: &nostr::Keys,
|
|
db_path: &Path,
|
|
) -> Result<u32, String> {
|
|
use crate::managed_agents::{
|
|
persona_events::monotonic_created_at,
|
|
retention::{get_retained_events_by_kind, open_retention_db, retain_event, RetainedEvent},
|
|
team_catalog::{
|
|
build_team_catalog_event, resolve_team_members, tombstone_team_catalog_coordinate,
|
|
},
|
|
TeamRecord,
|
|
};
|
|
use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG};
|
|
use nostr::JsonUtil;
|
|
|
|
let pubkey = keys.public_key().to_hex();
|
|
let conn =
|
|
open_retention_db(db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
|
|
|
|
// Enumerate retained 30178 heads as the authoritative worklist. A team
|
|
// deleted after a shared head was written is still visible here; iterating
|
|
// only the current team store would miss the orphan.
|
|
let all_heads = get_retained_events_by_kind(&conn, KIND_TEAM_CATALOG, &pubkey)?;
|
|
if all_heads.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
// Load teams once; missing is equivalent to empty (owner cleared the
|
|
// store). Load personas only when at least one shared head is found.
|
|
let teams: Vec<TeamRecord> = read_json_store(&base_dir.join("teams.json"))?;
|
|
let personas = read_persona_definitions(base_dir)?;
|
|
|
|
let mut reconciled = 0u32;
|
|
|
|
for head in &all_heads {
|
|
let head_event = nostr::Event::from_json(&head.raw_event).map_err(|e| {
|
|
format!(
|
|
"failed to parse retained head for d-tag '{}': {e}",
|
|
head.d_tag
|
|
)
|
|
})?;
|
|
|
|
// Only shared heads represent live community-visible state. An
|
|
// already-unshared head cannot be made worse by leaving it; a
|
|
// tombstone covers whole-coordinate deletion (delete_team).
|
|
if !event_is_shared(&head_event) {
|
|
continue;
|
|
}
|
|
|
|
// F1: the team no longer exists → the owner deleted it after sharing.
|
|
// Tombstone the coordinate so the community catalog stops showing it.
|
|
// The team-first loop could never see this case.
|
|
let Some(team) = teams.iter().find(|t| t.id == head.d_tag) else {
|
|
// Team name from the head's content for the notice, falling back
|
|
// to the d-tag when content is unparseable.
|
|
let team_name = (|| -> Option<String> {
|
|
let content: serde_json::Value =
|
|
serde_json::from_str(head_event.content.as_ref()).ok()?;
|
|
content.get("name")?.as_str().map(str::to_string)
|
|
})()
|
|
.unwrap_or_else(|| head.d_tag.clone());
|
|
let reason = "team no longer exists".to_string();
|
|
eprintln!("buzz-desktop: team-catalog-reconcile: tombstoning '{team_name}' — {reason}");
|
|
// `tombstone_team_catalog_coordinate` opens its own WAL connection;
|
|
// `conn` is kept alive for the retain_event calls in later
|
|
// iterations.
|
|
if let Err(e) = tombstone_team_catalog_coordinate(db_path, keys, &head.d_tag) {
|
|
eprintln!(
|
|
"buzz-desktop: team-catalog-reconcile: tombstone failed for '{}': {e}",
|
|
head.d_tag
|
|
);
|
|
} else {
|
|
reconciled += 1;
|
|
if let Some(app) = app {
|
|
emit_team_catalog_auto_retracted(app, &team_name, &reason);
|
|
}
|
|
}
|
|
continue;
|
|
};
|
|
|
|
// Built-in teams can never have been shared, but be defensive.
|
|
if team.is_builtin {
|
|
continue;
|
|
}
|
|
|
|
// Reproject from the current on-disk team and members. A failure is
|
|
// the retraction trigger: purge + tombstone the coordinate and notify
|
|
// the owner via a typed event. A stale-body "retraction" was rejected
|
|
// because an unshared-but-retained coordinate leaves the event live on
|
|
// the relay with no opt-in tag.
|
|
let rebuilt = resolve_team_members(team, &personas)
|
|
.and_then(|members| build_team_catalog_event(team, &members, true));
|
|
let builder = match rebuilt {
|
|
Ok(builder) => builder,
|
|
Err(reason) => {
|
|
eprintln!(
|
|
"buzz-desktop: team-catalog-reconcile: tombstoning '{}' — {reason}",
|
|
team.name
|
|
);
|
|
// `tombstone_team_catalog_coordinate` opens its own WAL
|
|
// connection; NOT dropping `conn` is what lets the loop keep
|
|
// processing remaining heads (I2 — multi-head continuation).
|
|
if let Err(e) = tombstone_team_catalog_coordinate(db_path, keys, &team.id) {
|
|
eprintln!(
|
|
"buzz-desktop: team-catalog-reconcile: tombstone failed for '{}': {e}",
|
|
team.name
|
|
);
|
|
} else {
|
|
reconciled += 1;
|
|
if let Some(app) = app {
|
|
emit_team_catalog_auto_retracted(app, &team.name, &reason);
|
|
}
|
|
}
|
|
// Continue to the next head — do not stop after the first
|
|
// tombstone (the original `drop(conn); return` was the I2 bug).
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let event = builder
|
|
// Supersede the retained head even when future-dated, as the
|
|
// persona and team reconciles do.
|
|
.custom_created_at(monotonic_created_at(Some(head.created_at)))
|
|
.sign_with_keys(keys)
|
|
.map_err(|e| format!("failed to sign catalog head for '{}': {e}", team.name))?;
|
|
|
|
// Compare the tag too, not just the body: an unshare replays the
|
|
// retained content verbatim, so bytes alone would report "unchanged"
|
|
// and leave the stale head shared.
|
|
if head.content == event.content && event_is_shared(&event) {
|
|
continue;
|
|
}
|
|
|
|
retain_event(
|
|
&conn,
|
|
&RetainedEvent {
|
|
kind: KIND_TEAM_CATALOG,
|
|
pubkey: pubkey.clone(),
|
|
d_tag: team.id.clone(),
|
|
content: event.content.to_string(),
|
|
created_at: event.created_at.as_secs() as i64,
|
|
raw_event: event.as_json(),
|
|
pending_sync: true,
|
|
},
|
|
)
|
|
.map_err(|e| format!("failed to retain catalog head for '{}': {e}", team.name))?;
|
|
reconciled += 1;
|
|
}
|
|
|
|
Ok(reconciled)
|
|
}
|
|
|
|
/// Emit a typed Tauri event so the frontend can show the owner a notice when
|
|
/// the boot reconcile automatically retracts a shared team.
|
|
///
|
|
/// Best-effort: a failed emit is logged but does not block reconcile.
|
|
fn emit_team_catalog_auto_retracted(app: &tauri::AppHandle, team_name: &str, reason: &str) {
|
|
use serde::Serialize;
|
|
use tauri::Emitter;
|
|
|
|
#[derive(Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct TeamCatalogAutoRetractedPayload<'a> {
|
|
team_name: &'a str,
|
|
reason: &'a str,
|
|
}
|
|
|
|
if let Err(e) = app.emit(
|
|
"team-catalog-auto-retracted",
|
|
TeamCatalogAutoRetractedPayload { team_name, reason },
|
|
) {
|
|
eprintln!("buzz-desktop: team-catalog-reconcile: failed to emit retraction notice: {e}");
|
|
}
|
|
}
|
|
|
|
/// Read `teams.json` strictly: an absent file is an empty store (every team
|
|
/// was deleted), but a malformed file is a fail-loud error — never an empty
|
|
/// read that would orphan every retained team head.
|
|
fn read_teams_strict(base_dir: &Path) -> Result<Vec<crate::managed_agents::TeamRecord>, String> {
|
|
read_json_store(&base_dir.join("teams.json"))
|
|
}
|
|
|
|
/// Validate `managed-agents.json` for the deletion sweep: absent is an empty
|
|
/// store, but a malformed file is preserved as `.invalid` and fails loud
|
|
/// (mirrors [`crate::managed_agents::reconcile`]'s contract) — a truncated file
|
|
/// backs the persona coordinates too, so it must never read as empty and orphan
|
|
/// live personas. The returned records are unused (managed-agent heads are not
|
|
/// swept), but reading strictly here aborts before `read_persona_definitions`
|
|
/// re-reads the same store.
|
|
fn read_agents_strict(
|
|
base_dir: &Path,
|
|
) -> Result<Vec<crate::managed_agents::ManagedAgentRecord>, String> {
|
|
let path = base_dir.join("managed-agents.json");
|
|
if !path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let content = std::fs::read_to_string(&path)
|
|
.map_err(|e| format!("failed to read managed-agents.json: {e}"))?;
|
|
serde_json::from_str(&content).map_err(|e| {
|
|
crate::managed_agents::storage::backup_invalid_store(&path);
|
|
format!("failed to parse managed-agents.json (preserved as .invalid): {e}")
|
|
})
|
|
}
|
|
|
|
/// Tombstone every retained head of `kind` whose coordinate no longer has a
|
|
/// matching disk record. Best-effort per head: a tombstone failure is logged
|
|
/// and the sweep continues, so one wedged coordinate never blocks the rest.
|
|
/// Returns the number of orphans tombstoned.
|
|
fn tombstone_orphan_heads(
|
|
conn: &rusqlite::Connection,
|
|
db_path: &Path,
|
|
keys: &nostr::Keys,
|
|
pubkey: &str,
|
|
kind: u32,
|
|
live_d_tags: &std::collections::HashSet<String>,
|
|
tombstone: fn(&Path, &nostr::Keys, &str) -> Result<(), String>,
|
|
) -> Result<u32, String> {
|
|
use crate::managed_agents::retention::get_retained_events_by_kind;
|
|
|
|
let mut tombstoned = 0u32;
|
|
// The SELECT fully materializes before the loop, so the head enumeration
|
|
// holds no cursor while each `tombstone` opens its own `BEGIN IMMEDIATE`
|
|
// connection (mirrors the 30178 catalog reconcile).
|
|
for head in get_retained_events_by_kind(conn, kind, pubkey)? {
|
|
if live_d_tags.contains(&head.d_tag) {
|
|
continue;
|
|
}
|
|
// The disk record is gone but its head survived — a tombstone whose
|
|
// atomic purge+enqueue rolled back. The head is still live on the
|
|
// relay, and boot reconcile enumerates disk records, so nothing else
|
|
// will ever retract it. Re-run the (idempotent) atomic tombstone.
|
|
eprintln!(
|
|
"buzz-desktop: deletion-reconcile: tombstoning orphan kind:{kind} head '{}'",
|
|
head.d_tag
|
|
);
|
|
match tombstone(db_path, keys, &head.d_tag) {
|
|
Ok(()) => tombstoned += 1,
|
|
Err(e) => eprintln!(
|
|
"buzz-desktop: deletion-reconcile: tombstone failed for kind:{kind} '{}': {e}",
|
|
head.d_tag
|
|
),
|
|
}
|
|
}
|
|
Ok(tombstoned)
|
|
}
|
|
|
|
/// Negative-side counterpart of the positive boot reconcile
|
|
/// ([`migrate_personas_to_events`]/[`migrate_teams_to_events`]): those retain a
|
|
/// head for every live disk record; this retracts a head that has NO live disk
|
|
/// record. Covers personas (30175) and teams (30176) only — see
|
|
/// [`reconcile_deleted_heads_at`] for why managed agents (30177) are excluded.
|
|
///
|
|
/// Deletion removes the authoritative JSON before best-effort tombstoning, so
|
|
/// an SQLite/sign/commit failure leaves the head retained but the record gone.
|
|
/// The positive legs enumerate disk records and would never revisit that
|
|
/// coordinate, so without this sweep the relay coordinate stays live forever.
|
|
/// Enumerating retained heads (not disk records) is the only worklist that can
|
|
/// see the orphan.
|
|
///
|
|
/// Runs after the positive legs so their just-retained live heads are matched
|
|
/// and skipped; only genuine orphans remain. Best-effort like the persona and
|
|
/// catalog legs — a cleanup failure is no worse than the pre-existing orphan.
|
|
fn reconcile_deleted_heads(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) {
|
|
use crate::managed_agents::managed_agents_base_dir;
|
|
|
|
let Ok(base_dir) = managed_agents_base_dir(app) else {
|
|
return;
|
|
};
|
|
|
|
match reconcile_deleted_heads_at(&base_dir, keys, db_path) {
|
|
Ok(0) => {}
|
|
Ok(tombstoned) => {
|
|
eprintln!("buzz-desktop: deletion-reconcile: {tombstoned} orphan heads tombstoned");
|
|
}
|
|
Err(e) => eprintln!("buzz-desktop: deletion-reconcile: {e}"),
|
|
}
|
|
}
|
|
|
|
/// Core deletion sweep, decoupled from the `AppHandle` for testing.
|
|
///
|
|
/// Reads the disk stores FIRST, before any tombstone: a malformed store fails
|
|
/// loud (and `managed-agents.json` is preserved as `.invalid`) so a truncated
|
|
/// file can never read as empty and orphan every head. Missing files are
|
|
/// legitimately empty — every record of that kind was deleted — so their
|
|
/// surviving persona/team heads are correctly tombstoned.
|
|
///
|
|
/// Managed agents (30177) are read only to validate the store, never swept:
|
|
/// their inbound sync retains a head WITHOUT minting a local disk record
|
|
/// (agents carry device-local secrets that can't come from a relay event), so a
|
|
/// retained 30177 head with no matching record is the normal cross-device state
|
|
/// for every agent created on another device — NOT a lost deletion. Sweeping it
|
|
/// would tombstone and archive another device's live agents at boot. Agent
|
|
/// deletion-retry therefore stays a pre-existing gap; the direct delete path
|
|
/// still owns the atomic 30177 tombstone + 9035 archive.
|
|
fn reconcile_deleted_heads_at(
|
|
base_dir: &Path,
|
|
keys: &nostr::Keys,
|
|
db_path: &Path,
|
|
) -> Result<u32, String> {
|
|
use crate::commands::{tombstone_persona_at, tombstone_team_at};
|
|
use crate::managed_agents::{persona_events::persona_d_tag, retention::open_retention_db};
|
|
use buzz_core_pkg::kind::{KIND_PERSONA, KIND_TEAM};
|
|
use std::collections::HashSet;
|
|
|
|
let pubkey = keys.public_key().to_hex();
|
|
|
|
// Validate managed-agents.json first (it backs persona coordinates
|
|
// post-fold): a parse failure here aborts with an `.invalid` backup before
|
|
// `read_persona_definitions` re-reads it. Managed agents (30177) are
|
|
// deliberately excluded from the sweep below — their inbound sync retains a
|
|
// head WITHOUT minting a local record (they carry device-local secrets), so
|
|
// "retained head + no disk record" is the NORMAL cross-device state, not a
|
|
// deletion. Tombstoning it would delete another device's agents at boot.
|
|
read_agents_strict(base_dir)?;
|
|
let persona_defs = read_persona_definitions(base_dir)?;
|
|
let teams = read_teams_strict(base_dir)?;
|
|
|
|
let persona_tags: HashSet<String> = persona_defs.iter().map(persona_d_tag).collect();
|
|
let team_tags: HashSet<String> = teams.into_iter().map(|team| team.id).collect();
|
|
|
|
let conn =
|
|
open_retention_db(db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
|
|
|
|
let mut tombstoned = 0u32;
|
|
tombstoned += tombstone_orphan_heads(
|
|
&conn,
|
|
db_path,
|
|
keys,
|
|
&pubkey,
|
|
KIND_PERSONA,
|
|
&persona_tags,
|
|
tombstone_persona_at,
|
|
)?;
|
|
tombstoned += tombstone_orphan_heads(
|
|
&conn,
|
|
db_path,
|
|
keys,
|
|
&pubkey,
|
|
KIND_TEAM,
|
|
&team_tags,
|
|
tombstone_team_at,
|
|
)?;
|
|
Ok(tombstoned)
|
|
}
|
|
|
|
/// Read a JSON array store, treating an absent file as empty.
|
|
fn read_json_store<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Vec<T>, String> {
|
|
if !path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
|
let content =
|
|
std::fs::read_to_string(path).map_err(|e| format!("failed to read {name}: {e}"))?;
|
|
serde_json::from_str(&content).map_err(|e| format!("failed to parse {name}: {e}"))
|
|
}
|
|
|
|
/// Test-accessible alias for `read_json_store`, used by the `pending` module's
|
|
/// `refresh_for_persona_at` testable seam without re-exporting the private fn.
|
|
#[cfg(test)]
|
|
pub(crate) fn read_json_store_pub<T: serde::de::DeserializeOwned>(
|
|
path: &Path,
|
|
) -> Result<Vec<T>, String> {
|
|
read_json_store(path)
|
|
}
|
|
|
|
/// Read every persona definition in the legacy shape, from whichever store
|
|
/// holds them.
|
|
///
|
|
/// Post-fold (Phase 1A.2) definitions are key-less records in the unified
|
|
/// agent store; `personas.json` survives only on a boot where the fold
|
|
/// errored. Both callers must read the same set — a reconcile that saw an
|
|
/// empty persona list would conclude every team's members were deleted.
|
|
fn read_persona_definitions(
|
|
base_dir: &Path,
|
|
) -> Result<Vec<crate::managed_agents::AgentDefinition>, String> {
|
|
let personas: Vec<crate::managed_agents::AgentDefinition> =
|
|
read_json_store(&base_dir.join("personas.json"))?;
|
|
if !personas.is_empty() {
|
|
return Ok(personas);
|
|
}
|
|
let all: Vec<crate::managed_agents::ManagedAgentRecord> =
|
|
read_json_store(&base_dir.join("managed-agents.json"))?;
|
|
Ok(all
|
|
.iter()
|
|
.filter(|record| record.pubkey.is_empty())
|
|
.filter_map(|record| record.to_definition_view())
|
|
.collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "event_sync_tests.rs"]
|
|
mod tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "event_sync_team_events_tests.rs"]
|
|
mod team_events_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "event_sync_team_catalog_tests.rs"]
|
|
mod team_catalog_tests;
|