mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
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>
269 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|