Files
buzz/desktop/src-tauri/src/event_sync.rs
Will Pfleger a7c7414a7f feat(desktop): implement 30178 team catalog backend (#5112)
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>
2026-08-27 17:44:58 -04:00

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;