Files
buzz/desktop/src/shared/api/tauriTeams.ts
Will Pfleger 2f66ee24a0 feat(desktop): add team sharing to community catalog (#3995)
Stack: [#5112](https://github.com/block/buzz/pull/5112) → this PR

Stacks on the team catalog backend PR (#5112). Contains the `desktop/**`
changes that turn the add-agent surface into a single Community Catalog
browsing both shared agents and shared teams.

## Owner catalog sync pipeline

`usePersonaSync.ts` gains a hydration pipeline in `startPersonaSync` so
a fresh device that comes online after another already published the
owner's 30178 catalog head reconstructs the complete state without
falsely retracting it. The owner's
persona/team/managed-agent/30178/deletion history is backfilled up front
(a live-only subscription gets no history — reconnect-replay's
since-cursor is undefined until the first live event), then a live
subscription takes over.

Four properties keep a fresh sync from purging the owner's valid shared
head:

- **Paged backfill with a safe termination guarantee.** The relay serves
each REQ newest-first and clamps `limit` to its advertised `max_limit`,
so a large owner's history overflows one page — a newer 30178/30176
could return in-page while an older required 30175 constituent falls
beyond it. `fetchOwnerHistoryToExhaustion` pages the full window with
the `until` time cursor (the only cursor the WS REQ filter exposes; the
DB `before_id` keyset is REST-only), deduping the inclusive-boundary
rows. A short page terminates normally. A full page whose oldest event
cannot advance the time-only cursor is a **dense boundary** — more than
one page of events share one `created_at` second, which the WS filter
has no `(created_at, id)` cursor to escape — and raises
`PersonaHistoryDenseBoundaryError` rather than silently completing as if
the history were exhausted and dropping the older constituents behind
it.
- **Constituents-before-catalog ordering.** Over the complete batch,
`orderCatalogHeadsLast` stably defers every 30178 head past its
30175/30176 constituents. Reconciling a 30178 head before its personas
hydrate makes the inbound team refresh fail member resolution and queue
a dominating false tombstone; deferring the heads guarantees the
constituents are all applied first, while newest-wins order within every
other coordinate is untouched.
- **Hydration boundary for concurrent live events.** The backfill fetch
and the live subscription start concurrently into one reconcile chain. A
live or replayed 30178 that arrives before the backfill reconciles its
constituents would reproduce the same false-tombstone purge, so live
events are buffered until the ordered backfill is dispatched and then
drained in arrival order. Steady-state live events (after hydration
completes) reconcile immediately.
- **Explicit backfill failure policy.** A transient history-fetch
rejection is retried with bounded backoff. When backfill cannot complete
— retries exhausted, or a deterministic dense boundary — the pipeline
enters a **degraded-live** state rather than leaving the subscription
permanently inert with live events accumulating in the buffer: the
hydration boundary still opens so buffered and future live events keep
reconciling, but the whole catalog dependency set is dropped — the 30178
head, its 30175/30176 constituents, and any kind-5 deletion carrying a
dependency-targeting `a` tag (classified by scanning *all* `a` tags,
matching the backend's deletion router, which `find_map`s across every
tag and routes the first signer-owned coordinate — so a malformed or
foreign first `a` tag ahead of an owned 30176 cannot slip a destructive
deletion through) — because backfill never fully hydrated the owner's
constituents. Dropping only the 30178 head is not enough: the backend
refreshes the catalog head after every team/persona save, and live
delivery is newest-first, so a 30176 edit that adds a *new* member would
reach the backend before that member's 30175 and falsely tombstone a
witness-holding device's valid team. Holding the prior hydrated run's
constituents on disk only proves the *old* revision is resolvable — it
says nothing about a new member — which is why the entire dependency set
is held rather than just the head. 30177 managed-agent runtime policy
stays live (it drives no catalog refresh). A degraded device stays stale
on team/persona edits until it self-heals on the next effect re-run
(restart, or an identity/community switch) — the correct trade against
destroying valid shared state.

## Data layer

Relay paging, signature verification, NIP-33 head selection, and
untrusted-content parsing for the kind `30178` team catalog live
natively in the `fetch_team_catalog` Tauri command (`team_catalog.rs`),
structurally mirroring `fetch_persona_catalog` (`persona_catalog.rs`). A
catalog refresh crosses IPC once and never verifies a signature on the
webview thread. `teamCatalogRelay.ts` is now a thin presentation and
local-linkage layer over the verified projection — it shapes entries for
display and links each to a local team, and never parses or verifies.

Parsing is all-or-nothing, identical to the add-time re-fetch in
`add_team_from_catalog`: a team with any invalid member fails to parse
and the publication is dropped from the catalog, matching persona
behavior. **Behavior delta:** the previously reviewed partial-render of
invalid-member teams — a warning banner on an entry that could never be
added — is removed. Invalid publications are dropped entirely rather
than surfaced as un-addable.

## Hooks

`useTeamCatalogRelay.ts` mirrors the persona catalog hook: a
community-keyed query over `fetch_team_catalog`, live invalidation on
kind `30178`, share/unshare, and add-from-catalog (which invalidates
both the teams and personas stores, since adopting a team copies its
members as local personas).

## CommunityCatalogDialog

Single unified surface replacing the former separate dialogs. Agents and
Teams appear as labeled sections with type-tagged selection and a
teams-preferred launch. `TeamsSection`'s discover entry and the
new-agent card both open this one dialog. `PersonaCatalogDialog.tsx` is
removed; persona browsing now lives inside the unified dialog.

## TeamShareDialog

Publishes and unshares team catalog entries via `set_team_shared`.

## e2e + screenshots

`team-catalog.spec.ts` covers the browse + adopt flow;
`team-catalog-screenshots.spec.ts` produces the pixel-regression set.
`e2eBridge.ts` gains `mockTeamCatalogPublications`, which mirrors the
native command's head selection and shared gate and performs only a
shallow `v`/`name`/`members`-array shape check; per-member validation
stays in the Rust command.

## Follow-ups

None. The shared agent-definition text-safety policy
(Unicode-control/bidi/zero-width rejection) already covers the team
surface: #5112's `validate_team_catalog_content` chokepoint gates every
field delivered verbatim to the harness on both the publish and adopt
paths, and this PR's parse layer consumes that verified projection
rather than re-validating on the webview thread.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-28 14:51:25 -04:00

269 lines
7.2 KiB
TypeScript

import { invokeTauri } from "@/shared/api/tauri";
import type {
AgentTeam,
CreateTeamInput,
TeamCatalogSourceCoordinate,
UpdateTeamInput,
} from "@/shared/api/types";
/** Wire shape of `TeamCatalogSource` — snake_case, like its parent record. */
type RawTeamCatalogSource = {
owner_pubkey: string;
team_d_tag: string;
};
type RawTeam = {
id: string;
name: string;
description: string | null;
instructions?: string | null;
persona_ids: string[];
is_builtin?: boolean;
shared?: boolean;
catalog_source?: RawTeamCatalogSource | null;
source_dir?: string | null;
is_symlink?: boolean;
symlink_target?: string | null;
version?: string | null;
created_at: string;
updated_at: string;
};
function fromRawCatalogSource(
source: RawTeamCatalogSource | null | undefined,
): TeamCatalogSourceCoordinate | null {
return source
? { ownerPubkey: source.owner_pubkey, teamDTag: source.team_d_tag }
: null;
}
function fromRawTeam(team: RawTeam): AgentTeam {
return {
id: team.id,
name: team.name,
description: team.description,
instructions: team.instructions ?? null,
personaIds: team.persona_ids,
isBuiltin: team.is_builtin ?? false,
shared: team.shared ?? false,
catalogSource: fromRawCatalogSource(team.catalog_source),
sourceDir: team.source_dir ?? null,
isSymlink: team.is_symlink ?? false,
symlinkTarget: team.symlink_target ?? null,
version: team.version ?? null,
createdAt: team.created_at,
updatedAt: team.updated_at,
};
}
export async function listTeams(): Promise<AgentTeam[]> {
return (await invokeTauri<RawTeam[]>("list_teams")).map(fromRawTeam);
}
export async function createTeam(input: CreateTeamInput): Promise<AgentTeam> {
return fromRawTeam(
await invokeTauri<RawTeam>("create_team", {
input: {
name: input.name,
description: input.description,
instructions: input.instructions,
personaIds: input.personaIds,
},
}),
);
}
export async function updateTeam(input: UpdateTeamInput): Promise<AgentTeam> {
return fromRawTeam(
await invokeTauri<RawTeam>("update_team", {
input: {
id: input.id,
name: input.name,
description: input.description,
instructions: input.instructions,
personaIds: input.personaIds,
},
}),
);
}
export async function deleteTeam(id: string): Promise<void> {
await invokeTauri("delete_team", { id });
}
// ── Team catalog commands ────────────────────────────────────────────────────
export type TeamSharePublicationResult = {
team: AgentTeam;
/** `queued` means the head is durably enqueued but the relay has not yet
* accepted it, so catalog visibility lags the toggle. */
publicationStatus: "published" | "queued";
};
type RawTeamSharePublicationResult = {
team: RawTeam;
publicationStatus: "published" | "queued";
};
/** Publish this team's catalog head, or replace it with an untagged one. */
export async function setTeamShared(
id: string,
shared: boolean,
): Promise<TeamSharePublicationResult> {
const raw = await invokeTauri<RawTeamSharePublicationResult>(
"set_team_shared",
{ id, shared },
);
return {
team: fromRawTeam(raw.team),
publicationStatus: raw.publicationStatus,
};
}
export type AddTeamFromCatalogResult = {
team: AgentTeam;
/** True when the team was already added and nothing was written. */
alreadyPresent: boolean;
};
type RawAddTeamFromCatalogResult = {
team: RawTeam;
alreadyPresent: boolean;
};
/**
* Copy a published team into the local stores.
*
* Only the coordinate crosses the boundary — never the projection the UI is
* displaying. The backend re-fetches the current head at
* `30178:<owner>:<d-tag>` and rejects the add if it is not `eventId` or has
* stopped being shared, so a catalog entry that moved or was retracted while
* the dialog sat open cannot be copied.
*/
export async function addTeamFromCatalog(
source: TeamCatalogSourceCoordinate & { eventId: string },
): Promise<AddTeamFromCatalogResult> {
const raw = await invokeTauri<RawAddTeamFromCatalogResult>(
"add_team_from_catalog",
{
input: {
ownerPubkey: source.ownerPubkey,
teamDTag: source.teamDTag,
eventId: source.eventId,
},
},
);
return { team: fromRawTeam(raw.team), alreadyPresent: raw.alreadyPresent };
}
// ── Team snapshot types ─────────────────────────────────────────────────────
export type SnapshotFormat = "json" | "png";
export type SnapshotMemoryLevel = "none" | "core" | "everything";
export type EncodedTeamSnapshotPayload = {
fileBytes: number[];
fileName: string;
};
export type TeamSnapshotMemberPreview = {
displayName: string;
systemPrompt: string | null;
avatarUrl: string | null;
hasSourceAllowlist: boolean;
sourceAllowlistCount: number;
};
export type TeamSnapshotImportPreview = {
name: string;
description: string | null;
instructions: string | null;
members: TeamSnapshotMemberPreview[];
hasSourceAllowlist: boolean;
};
export type TeamSnapshotImportConfirm = {
fileBytes: number[];
keepAllowlist: boolean;
};
export type TeamSnapshotImportMemberResult = {
displayName: string;
pubkey: string;
personaId: string;
memoryWritten: number;
memoryTotal: number;
memoryErrors: string[];
profileSyncError: string | null;
};
/** Raw wire shape of the import result — outer struct is camelCase,
* but the nested `team` field is snake_case (no `rename_all` on TeamRecord). */
type RawTeamSnapshotImportResult = {
team: RawTeam;
personaIds: string[];
members: TeamSnapshotImportMemberResult[];
};
export type TeamSnapshotImportResult = {
team: AgentTeam;
personaIds: string[];
members: TeamSnapshotImportMemberResult[];
};
// ── Team snapshot commands ───────────────────────────────────────────────────
export async function exportTeamSnapshot(
id: string,
memoryLevel: SnapshotMemoryLevel,
format: SnapshotFormat,
): Promise<boolean> {
return invokeTauri<boolean>("export_team_snapshot", {
id,
memoryLevel,
format,
});
}
export async function encodeTeamSnapshotForSend(
id: string,
memoryLevel: SnapshotMemoryLevel,
format: SnapshotFormat,
): Promise<EncodedTeamSnapshotPayload> {
return invokeTauri<EncodedTeamSnapshotPayload>(
"encode_team_snapshot_for_send",
{
id,
memoryLevel,
format,
},
);
}
export async function previewTeamSnapshotImport(
fileBytes: number[],
fileName: string,
): Promise<TeamSnapshotImportPreview> {
return invokeTauri<TeamSnapshotImportPreview>(
"preview_team_snapshot_import",
{
fileBytes,
fileName,
},
);
}
export async function confirmTeamSnapshotImport(
input: TeamSnapshotImportConfirm,
): Promise<TeamSnapshotImportResult> {
const raw = await invokeTauri<RawTeamSnapshotImportResult>(
"confirm_team_snapshot_import",
{ input },
);
return {
team: fromRawTeam(raw.team),
personaIds: raw.personaIds,
members: raw.members,
};
}