Files
buzz/desktop/src/features/projects/useAddProjectRepository.ts
2026-08-31 16:54:06 -04:00

336 lines
13 KiB
TypeScript

import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
type Project,
projectsQueryKey,
type Repository,
} from "@/features/projects/hooks";
import { isUnsupportedProjectKindError } from "@/features/projects/projectCreation";
import {
isDanglingProjectMemberPublish,
publishOwnedAgentProjectAnnouncements,
} from "@/features/projects/projectOwnerControl";
import {
buildAddedRepositoryEventTemplatesFromHead,
repositoryDtagFromName,
} from "@/features/projects/projectRepositoryCreation";
import {
addRepositoryToProject,
eventToRepository,
} from "@/features/projects/projectModels";
import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot";
import { publishProjectOwnerAnnouncement } from "@/shared/api/projectGit";
import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
import {
KIND_PROJECT_ANNOUNCEMENT,
KIND_REPO_ANNOUNCEMENT,
} from "@/shared/constants/kinds";
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
export type AddProjectRepositoryInput = {
accessChannelId?: string;
cloneUrl?: string;
description?: string;
name: string;
ownerControlAgentPubkey?: string;
project: Project;
webUrl?: string;
};
type FetchEventsInput = Parameters<(typeof relayClient)["fetchEvents"]>[0];
/**
* Relay/publish seams injected by tests. The regression for the
* partial-publish heal must run the mutation itself — guards included — not
* just the template builder, so the whole flow takes its I/O through here.
*/
export type AddProjectRepositoryDeps = {
fetchEvents: (filter: FetchEventsInput) => Promise<RelayEvent[]>;
publishOwnedAgentAnnouncements: typeof publishOwnedAgentProjectAnnouncements;
publishOwnerAnnouncement: typeof publishProjectOwnerAnnouncement;
};
/** Exported for tests — production callers go through the mutation hook. */
export async function addProjectRepository(
{ ownerControlAgentPubkey, project, ...input }: AddProjectRepositoryInput,
deps?: Partial<AddProjectRepositoryDeps>,
): Promise<{
previousProjectId: string;
project: Project;
repository: Repository;
}> {
const {
fetchEvents = relayClient.fetchEvents.bind(relayClient),
publishOwnedAgentAnnouncements = publishOwnedAgentProjectAnnouncements,
publishOwnerAnnouncement = publishProjectOwnerAnnouncement,
} = deps ?? {};
const targetOwner = project.owner.toLowerCase();
// Fetch the live signed project head immediately before mutating.
// This prevents: (a) unknown-tag erasure from the cached UI projection,
// (b) concurrent-write clobber when another session or the CLI advanced
// the head after we loaded the page.
const liveHeads = await fetchEvents({
kinds: [KIND_PROJECT_ANNOUNCEMENT],
authors: [targetOwner],
"#d": [project.dtag],
limit: 1,
});
const liveHead = liveHeads[0];
if (!liveHead) {
throw new Error(
"Could not find this project on the relay. Refresh and try again.",
);
}
// Partial-publish detection must precede the dominated-write guard below:
// the project event can land while the repository event fails, leaving the
// live head referencing a coordinate with no repository head (a dangling
// member) — and a live head strictly newer than the cached snapshot,
// because the failed mutation never updated the cache. Probe for a
// repository head at the coordinate first so the template builder can
// distinguish "raced with another session" (throw) from "resume a partial
// publish" (heal by publishing only the missing event).
const repositoryDtag = repositoryDtagFromName(input.name.trim());
const existingRepoHeads = repositoryDtag
? await fetchEvents({
kinds: [KIND_REPO_ANNOUNCEMENT],
authors: [targetOwner],
"#d": [repositoryDtag],
limit: 1,
})
: [];
const templates = buildAddedRepositoryEventTemplatesFromHead({
...input,
existingRepositoryAddresses: project.repositoryAddresses,
liveHead,
ownerPubkey: targetOwner,
relatedChannelIds: project.relatedChannelIds,
repositoryHeadExists: existingRepoHeads.length > 0,
});
// Cross-project d-tag clobber guard: if the owner already has a 30617
// at this coordinate — whether as a standalone repo or in another project —
// block the write unconditionally. (A coordinate that is already a member
// of this project with a live repository head threw "already contains"
// inside the template builder before reaching here.)
if (existingRepoHeads.length > 0) {
throw new Error(
`A repository named "${templates.repositoryDtag}" already exists (as a standalone repository or in another project). Choose a different name to avoid overwriting it.`,
);
}
// Dominated-write guard: if the live head is newer than our cached snapshot,
// a concurrent session has already advanced the project — our mutation would
// overwrite their changes. Surface the conflict rather than silently
// clobbering. Resume mode publishes no project head, so a moved head cannot
// be clobbered and must not block the heal.
if (!templates.resume && liveHead.created_at > project.createdAt) {
throw new Error(
"This project was updated by another session while you were working. Refresh and try again.",
);
}
const projectCreatedAt = Math.max(
Math.floor(Date.now() / 1_000),
liveHead.created_at + 1,
);
if (ownerControlAgentPubkey) {
// Resume mode: the live project head already references the coordinate,
// so republishing it would only advance created_at for nothing — send
// just the missing repository event.
let events: RelayEvent[];
try {
events = await publishOwnedAgentAnnouncements(
ownerControlAgentPubkey,
templates.resume
? [{ ...templates.repository, createdAt: projectCreatedAt }]
: [
{ ...templates.project, createdAt: projectCreatedAt },
{ ...templates.repository, createdAt: projectCreatedAt },
],
);
} catch (error) {
// Sequential publish failed partway: the project head landed but the
// repository event did not. Consume the partial-success metadata and
// resume immediately with just the missing event, instead of
// surfacing a dangling member for the user to retry.
if (!isDanglingProjectMemberPublish(error)) {
throw error;
}
const repositoryEvents = await publishOwnedAgentAnnouncements(
ownerControlAgentPubkey,
[{ ...templates.repository, createdAt: projectCreatedAt }],
);
events = [...error.publishedEvents, ...repositoryEvents];
}
const projectEvent = templates.resume
? liveHead
: events.find((event) => event.kind === KIND_PROJECT_ANNOUNCEMENT);
const repositoryEvent = events.find(
(event) => event.kind === KIND_REPO_ANNOUNCEMENT,
);
const repository = repositoryEvent
? eventToRepository(repositoryEvent, getCachedRelayOrigin())
: null;
if (!projectEvent || !repository) {
throw new Error(
"The owner agent updated the project but its response was incomplete. Refresh and try again.",
);
}
return {
previousProjectId: project.id,
project: addRepositoryToProject(
project,
repository,
projectEvent.created_at,
),
repository,
};
}
let projectEvent: RelayEvent;
if (templates.resume) {
// The live head already references the coordinate — publishing the
// missing repository event below is the entire heal.
projectEvent = liveHead;
} else {
try {
// Confirm grouping support before publishing the repository so older
// relays cannot leave a new standalone repository behind.
const publication = await publishOwnerAnnouncement({
...templates.project,
createdAt: projectCreatedAt,
targetOwner,
});
projectEvent = publication.event;
if (publication.publicationError) {
throw new Error(publication.publicationError);
}
} catch (error) {
if (isUnsupportedProjectKindError(error)) {
throw new Error(
`This relay does not support multi-repository projects (event kind ${KIND_PROJECT_ANNOUNCEMENT}).`,
);
}
throw error;
}
}
// If the repository publish fails, the project event is already live with a
// dangling member. Attempt to recover automatically before surfacing an error:
//
// 1. Query the relay by event id to distinguish two failure modes:
// - Rejected ("OK false"): the relay explicitly refused the event. Retry
// once; if the retry also fails, the operation is genuinely unrecoverable.
// - Lost acknowledgement ("OK false" with ok:false but the relay stored it):
// the event reached the relay but the transport-level ACK was lost. A
// subsequent id-keyed query will find it; treat that as success.
//
// This keeps the mutation idempotent: re-submitting the same signed event is
// safe because event ids are deterministic hashes of the content+pubkey+timestamp.
let repository: Repository | null = null;
let repositoryEvent: RelayEvent | null = null;
const publishRepository = async (): Promise<RelayEvent> => {
const publication = await publishOwnerAnnouncement({
...templates.repository,
createdAt: projectCreatedAt,
targetOwner,
});
repositoryEvent = publication.event;
if (publication.publicationError) {
throw new Error(publication.publicationError);
}
return publication.event;
};
try {
repositoryEvent = await publishRepository();
repository = eventToRepository(repositoryEvent, getCachedRelayOrigin());
if (!repository) {
throw new Error("The repository was created but could not be read.");
}
} catch (_repoError) {
// Query by event id: if the relay already has this event, the ACK was
// merely lost — the write succeeded and we can proceed normally.
let alreadyStored = false;
if (repositoryEvent) {
try {
const stored = await fetchEvents({
ids: [repositoryEvent.id],
kinds: [KIND_REPO_ANNOUNCEMENT],
limit: 1,
});
alreadyStored = stored.length > 0;
} catch {
// Ignore — if the query itself fails we fall through to the retry.
}
}
if (alreadyStored && repositoryEvent) {
repository = eventToRepository(repositoryEvent, getCachedRelayOrigin());
} else {
// The relay rejected or never received the event. Retry once with the
// same signed event (safe — deterministic id).
try {
repositoryEvent = await publishRepository();
repository = eventToRepository(repositoryEvent, getCachedRelayOrigin());
} catch (retryError) {
const message =
retryError instanceof Error
? retryError.message
: "Failed to create the repository.";
// The project already lists the coordinate. Surface a partial-write
// error with the event id so the user (or a future repair pass) can
// query the relay directly and re-submit the signed event if needed.
const eventLabel = repositoryEvent
? `event ${repositoryEvent.id.slice(0, 8)}…`
: "an event that could not be signed";
throw new Error(
`The project was updated but the repository could not be created (${eventLabel}): ${message} ` +
`The add-repository operation is idempotent — re-run Add repository with the same name to resume: the recovery query will find the stored event or retry the publish.`,
);
}
}
if (!repository && repositoryEvent) {
repository = eventToRepository(repositoryEvent, getCachedRelayOrigin());
}
if (!repository) {
throw new Error("The repository was created but could not be read.");
}
}
return {
previousProjectId: project.id,
project: addRepositoryToProject(
project,
repository,
projectEvent.created_at,
),
repository,
};
}
export function useAddProjectRepositoryMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: AddProjectRepositoryInput) =>
addProjectRepository(input),
onSuccess: ({ previousProjectId, project }) => {
markProjectDataAuthoritative(project, "local-write");
if (previousProjectId !== project.id) {
queryClient.removeQueries({
exact: true,
queryKey: ["project", previousProjectId],
});
}
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
current.map((candidate) =>
candidate.id === previousProjectId ? project : candidate,
),
);
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
}