Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/buzz-relay/src/api/mesh_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,12 @@ mod tests {
.query_async::<String>(&mut *conn)
.await
.ok()?;
// Keep test leases well above QUIC setup + task-spawn latency under
// full-suite parallelism so the owner lease cannot expire mid-forward
// (#2458). Production default is 30s; tests do not exercise expiry.
Some(SessionDirectory::with_lease_ttl(
pool,
Duration::from_secs(5),
Duration::from_secs(60),
))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const PAGE_BACK_CLASS =
const MODAL_PRIMARY_ACTION_CLASS = `${ONBOARDING_PRIMARY_CTA_CLASS} !text-[rgb(var(--buzz-hosted-community-modal-action-fg))]`;
const MODAL_BACK_ACTION_CLASS =
"h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15";
/** Hosted Builderlab OAuth can hang forever on TLS/network failures (#2484). */
const BUILDERLAB_SIGN_IN_TIMEOUT_MS = 45_000;

type HostedCommunityOnboardingProps = {
onBack: () => void;
Expand Down Expand Up @@ -135,18 +137,35 @@ export function HostedCommunityOnboarding({
const attempt = ++loginAttempt.current;
setAction("Signing in…");
setError(null);
void startBuilderlabLogin()
let timeoutId = 0;
const timeout = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
reject(
new Error(
"Builderlab sign-in timed out. Retry, or go back and connect to an existing / self-hosted relay.",
),
);
}, BUILDERLAB_SIGN_IN_TIMEOUT_MS);
});
void Promise.race([startBuilderlabLogin(), timeout])
.then(async (nextAuth) => {
window.clearTimeout(timeoutId);
if (loginAttempt.current !== attempt) return;
setAuth(nextAuth);
await loadAccount();
})
.catch((cause) => {
window.clearTimeout(timeoutId);
if (loginAttempt.current !== attempt) return;
// Invalidate any late success from the still-running native login.
loginAttempt.current += 1;
setError(cause instanceof Error ? cause.message : String(cause));
void cancelBuilderlabLogin().catch(() => {
// Best-effort cleanup when the browser flow never returns.
});
})
.finally(() => {
if (loginAttempt.current === attempt) setAction(null);
setAction((current) => (current === "Signing in…" ? null : current));
});
};

Expand Down Expand Up @@ -496,18 +515,30 @@ export function HostedCommunityOnboarding({
Waiting for your browser…
</Button>
) : (
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
onClick={signIn}
>
Sign in to continue
</Button>
<div className="mt-6 flex w-full flex-col items-stretch gap-2">
<Button
className={MODAL_PRIMARY_ACTION_CLASS}
onClick={signIn}
>
{error ? "Retry sign in" : "Sign in to continue"}
</Button>
{error ? (
<Button
className={MODAL_BACK_ACTION_CLASS}
onClick={cancelSignInAndGoBack}
variant="ghost"
>
Back — use another relay
</Button>
) : null}
</div>
)}
{/* Quiet breadcrumb: Buzz itself is open source; this hosted
relay is the one account-backed piece of the flow. */}
<p className="mt-6 w-full border-t border-foreground/10 pt-4 text-xs leading-5 text-foreground/45">
Buzz is open source. Builderlab hosts the relay for this
account.
account. If hosted setup fails, go back and connect to a
self-hosted or existing relay instead.
</p>
</>
) : !identity ? (
Expand Down
141 changes: 15 additions & 126 deletions desktop/src/features/projects/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
KIND_GIT_STATUS_OPEN,
KIND_REPO_ANNOUNCEMENT,
KIND_REPO_STATE,
KIND_STREAM_MESSAGE,
KIND_TEXT_NOTE,
} from "@/shared/constants/kinds";
import type {
Expand All @@ -47,11 +48,11 @@ import type {
ProjectPullRequest,
ProjectPullRequestCommentAnchor,
} from "./projectPullRequests.mjs";
import { projectPullRequestEventsToPullRequests } from "./projectPullRequests.mjs";
import {
normalizeProjectPullRequestCommentAnchor,
PR_INLINE_COMMENT_LABEL,
projectPullRequestEventsToPullRequests,
} from "./projectPullRequests.mjs";
createProjectIssueComment,
createProjectPullRequestComment,
} from "./projectCommentPublish";
import { fetchProjectsWorkItems } from "./projectWorkItems";

export type {
Expand Down Expand Up @@ -200,14 +201,15 @@ export function eventToProject(
const webUrl = getTag(event, "web") ?? null;
const setupUsers = getAllTags(event, "auth");
const contributors = [...new Set([...getAllTags(event, "p"), ...setupUsers])];
// `h`/`project-channel`, `status`, and `default-branch` are NOT part of
// NIP-34 — they are read-side tolerance for extension tags no code writes
// today (the write path that emitted them was removed). If a write path is
// reintroduced it must go through the buzz-sdk repo-announcement builder;
// the canonical NIP-34 source for the default branch is the kind:30618
// state event's HEAD ref, not a 30617 tag.
// Channel binding is a Buzz extension on kind:30617. Prefer the canonical
// `buzz-channel` tag (buzz-sdk / git policy / web), then legacy `h` /
// `project-channel`. `status` and `default-branch` remain read-side
// tolerance only — default branch comes from kind:30618 HEAD.
const projectChannelId =
getTag(event, "h") ?? getTag(event, "project-channel") ?? null;
getTag(event, "buzz-channel") ??
getTag(event, "h") ??
getTag(event, "project-channel") ??
null;

return {
id: `${event.pubkey}:${d}`,
Expand Down Expand Up @@ -382,7 +384,7 @@ async function fetchProjectIssues(project: Project): Promise<ProjectIssue[]> {
limit: 500,
}),
relayClient.fetchEvents({
kinds: [KIND_TEXT_NOTE],
kinds: [KIND_TEXT_NOTE, KIND_STREAM_MESSAGE],
"#a": [project.repoAddress],
limit: 500,
}),
Expand All @@ -407,7 +409,7 @@ async function fetchProjectPullRequests(
limit: 500,
}),
relayClient.fetchEvents({
kinds: [KIND_TEXT_NOTE],
kinds: [KIND_TEXT_NOTE, KIND_STREAM_MESSAGE],
"#a": [project.repoAddress],
limit: 500,
}),
Expand All @@ -431,119 +433,6 @@ async function fetchProjectPullRequests(
);
}

// Issue/PR comments are published as kind:1 text notes because the relay
// does not register NIP-22 kind 1111 (current NIP-34 reply convention).
// Pulse feeds filter these out via the repo-address `a` tag (see
// features/pulse/lib/projectComments.ts). If the relay ever allowlists
// 1111, migrate these to NIP-22 comments and drop that filter.
async function createProjectPullRequestComment({
anchor,
content,
mediaTags,
mentionPubkeys = [],
project,
pullRequest,
}: {
anchor?: ProjectPullRequestCommentAnchor;
content: string;
mediaTags?: string[][];
mentionPubkeys?: string[];
project: Project;
pullRequest: ProjectPullRequest;
}): Promise<void> {
const body = content.trim();
if (!body) {
throw new Error("Comment cannot be empty.");
}
const normalizedAnchor = anchor
? normalizeProjectPullRequestCommentAnchor(anchor)
: null;
if (anchor && !normalizedAnchor) {
throw new Error("Comment location is invalid.");
}
if (normalizedAnchor && !pullRequest.commit) {
throw new Error("Pull request commit is required for inline comments.");
}

const recipients = new Set([
project.owner.toLowerCase(),
pullRequest.author.toLowerCase(),
...pullRequest.recipients.map((recipient) => recipient.toLowerCase()),
...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
]);
const tags = [
["e", pullRequest.id, "", "root"],
["a", project.repoAddress],
...[...recipients].map((recipient) => ["p", recipient]),
...(normalizedAnchor
? [
["t", PR_INLINE_COMMENT_LABEL],
["c", pullRequest.commit as string],
["file", normalizedAnchor.path],
["side", normalizedAnchor.side],
["line", String(normalizedAnchor.line)],
]
: []),
...(mediaTags ?? []),
];

const event = await signRelayEvent({
kind: KIND_TEXT_NOTE,
content: body,
tags,
});

await relayClient.publishEvent(
event,
"Timed out posting pull request comment.",
"Failed to post pull request comment.",
);
}

async function createProjectIssueComment({
content,
mediaTags,
mentionPubkeys = [],
issue,
project,
}: {
content: string;
mediaTags?: string[][];
mentionPubkeys?: string[];
issue: ProjectIssue;
project: Project;
}): Promise<void> {
const body = content.trim();
if (!body) {
throw new Error("Comment cannot be empty.");
}

const recipients = new Set([
project.owner.toLowerCase(),
issue.author.toLowerCase(),
...issue.recipients.map((recipient) => recipient.toLowerCase()),
...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
]);
const tags = [
["e", issue.id, "", "root"],
["a", project.repoAddress],
...[...recipients].map((recipient) => ["p", recipient]),
...(mediaTags ?? []),
];

const event = await signRelayEvent({
kind: KIND_TEXT_NOTE,
content: body,
tags,
});

await relayClient.publishEvent(
event,
"Timed out posting issue comment.",
"Failed to post issue comment.",
);
}

async function fetchProjectRepoSnapshot(
project: Project,
branchName?: string | null,
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/features/projects/projectCommentPublish.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function projectCommentKindAndChannelTags(
project: {
owner: string;
projectChannelId: string | null;
repoAddress: string;
},
mentionPubkeys: string[],
): { kind: number; channelTags: string[][] };
28 changes: 28 additions & 0 deletions desktop/src/features/projects/projectCommentPublish.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
KIND_STREAM_MESSAGE,
KIND_TEXT_NOTE,
} from "../../shared/constants/kinds.ts";

/**
* Issue/PR comments without agent mentions stay kind:1 (relay has no NIP-22
* kind 1111). Mentions must be kind:9 + channel `h` so buzz-acp's per-channel
* Mentions subscription (#h + #p + kinds [9,…]) can deliver them — see #2462.
*/
export function projectCommentKindAndChannelTags(project, mentionPubkeys) {
if (mentionPubkeys.length === 0) {
return { kind: KIND_TEXT_NOTE, channelTags: [] };
}
const channelId =
typeof project.projectChannelId === "string"
? project.projectChannelId.trim()
: "";
if (!channelId) {
throw new Error(
"This project has no discussion channel. Link a channel before @mentioning agents, or mention them in a channel instead.",
);
}
return {
kind: KIND_STREAM_MESSAGE,
channelTags: [["h", channelId]],
};
}
46 changes: 46 additions & 0 deletions desktop/src/features/projects/projectCommentPublish.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";

import { KIND_STREAM_MESSAGE, KIND_TEXT_NOTE } from "@/shared/constants/kinds";
import { projectCommentKindAndChannelTags } from "./projectCommentPublish.mjs";

test("plain project comments stay kind:1 without a channel tag", () => {
const result = projectCommentKindAndChannelTags(
{
owner: "a".repeat(64),
projectChannelId: "channel-1",
repoAddress: "30617:owner:repo",
},
[],
);
assert.equal(result.kind, KIND_TEXT_NOTE);
assert.deepEqual(result.channelTags, []);
});

test("agent mentions publish as kind:9 with the project channel h tag", () => {
const result = projectCommentKindAndChannelTags(
{
owner: "a".repeat(64),
projectChannelId: "channel-1",
repoAddress: "30617:owner:repo",
},
["b".repeat(64)],
);
assert.equal(result.kind, KIND_STREAM_MESSAGE);
assert.deepEqual(result.channelTags, [["h", "channel-1"]]);
});

test("agent mentions without a project channel fail closed", () => {
assert.throws(
() =>
projectCommentKindAndChannelTags(
{
owner: "a".repeat(64),
projectChannelId: null,
repoAddress: "30617:owner:repo",
},
["b".repeat(64)],
),
/discussion channel/i,
);
});
Loading