From df91f0f879f788498e6f580d0fef4b32d961ee38 Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 14:05:52 +0300 Subject: [PATCH 1/3] fix(desktop): deliver project @mentions to buzz-acp via kind:9 + h Signed-off-by: Taksh Co-authored-by: Cursor --- desktop/src/features/projects/hooks.ts | 141 ++---------------- .../projects/projectCommentPublish.d.mts | 8 + .../projects/projectCommentPublish.mjs | 28 ++++ .../projects/projectCommentPublish.test.mjs | 46 ++++++ .../projects/projectCommentPublish.ts | 139 +++++++++++++++++ .../src/features/pulse/lib/projectComments.ts | 10 +- 6 files changed, 241 insertions(+), 131 deletions(-) create mode 100644 desktop/src/features/projects/projectCommentPublish.d.mts create mode 100644 desktop/src/features/projects/projectCommentPublish.mjs create mode 100644 desktop/src/features/projects/projectCommentPublish.test.mjs create mode 100644 desktop/src/features/projects/projectCommentPublish.ts diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a1761f0b11..e2e99677b1 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -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 { @@ -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 { @@ -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}`, @@ -382,7 +384,7 @@ async function fetchProjectIssues(project: Project): Promise { limit: 500, }), relayClient.fetchEvents({ - kinds: [KIND_TEXT_NOTE], + kinds: [KIND_TEXT_NOTE, KIND_STREAM_MESSAGE], "#a": [project.repoAddress], limit: 500, }), @@ -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, }), @@ -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 { - 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 { - 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, diff --git a/desktop/src/features/projects/projectCommentPublish.d.mts b/desktop/src/features/projects/projectCommentPublish.d.mts new file mode 100644 index 0000000000..2444f2f143 --- /dev/null +++ b/desktop/src/features/projects/projectCommentPublish.d.mts @@ -0,0 +1,8 @@ +export function projectCommentKindAndChannelTags( + project: { + owner: string; + projectChannelId: string | null; + repoAddress: string; + }, + mentionPubkeys: string[], +): { kind: number; channelTags: string[][] }; diff --git a/desktop/src/features/projects/projectCommentPublish.mjs b/desktop/src/features/projects/projectCommentPublish.mjs new file mode 100644 index 0000000000..aa27540e39 --- /dev/null +++ b/desktop/src/features/projects/projectCommentPublish.mjs @@ -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]], + }; +} diff --git a/desktop/src/features/projects/projectCommentPublish.test.mjs b/desktop/src/features/projects/projectCommentPublish.test.mjs new file mode 100644 index 0000000000..77adca3069 --- /dev/null +++ b/desktop/src/features/projects/projectCommentPublish.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/features/projects/projectCommentPublish.ts b/desktop/src/features/projects/projectCommentPublish.ts new file mode 100644 index 0000000000..2024c1f548 --- /dev/null +++ b/desktop/src/features/projects/projectCommentPublish.ts @@ -0,0 +1,139 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; + +import type { ProjectIssue } from "./projectIssues.mjs"; +import { projectCommentKindAndChannelTags } from "./projectCommentPublish.mjs"; +import type { + ProjectPullRequest, + ProjectPullRequestCommentAnchor, +} from "./projectPullRequests.mjs"; +import { + normalizeProjectPullRequestCommentAnchor, + PR_INLINE_COMMENT_LABEL, +} from "./projectPullRequests.mjs"; + +export { projectCommentKindAndChannelTags } from "./projectCommentPublish.mjs"; + +type ProjectCommentTarget = { + owner: string; + projectChannelId: string | null; + repoAddress: string; +}; + +export async function createProjectPullRequestComment({ + anchor, + content, + mediaTags, + mentionPubkeys = [], + project, + pullRequest, +}: { + anchor?: ProjectPullRequestCommentAnchor; + content: string; + mediaTags?: string[][]; + mentionPubkeys?: string[]; + project: ProjectCommentTarget; + pullRequest: ProjectPullRequest; +}): Promise { + 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 { kind, channelTags } = projectCommentKindAndChannelTags( + project, + mentionPubkeys, + ); + const recipients = new Set([ + project.owner.toLowerCase(), + pullRequest.author.toLowerCase(), + ...pullRequest.recipients.map((recipient) => recipient.toLowerCase()), + ...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()), + ]); + const tags = [ + ...channelTags, + ["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, + content: body, + tags, + }); + + await relayClient.publishEvent( + event, + "Timed out posting pull request comment.", + "Failed to post pull request comment.", + ); +} + +export async function createProjectIssueComment({ + content, + mediaTags, + mentionPubkeys = [], + issue, + project, +}: { + content: string; + mediaTags?: string[][]; + mentionPubkeys?: string[]; + issue: ProjectIssue; + project: ProjectCommentTarget; +}): Promise { + const body = content.trim(); + if (!body) { + throw new Error("Comment cannot be empty."); + } + + const { kind, channelTags } = projectCommentKindAndChannelTags( + project, + mentionPubkeys, + ); + const recipients = new Set([ + project.owner.toLowerCase(), + issue.author.toLowerCase(), + ...issue.recipients.map((recipient) => recipient.toLowerCase()), + ...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()), + ]); + const tags = [ + ...channelTags, + ["e", issue.id, "", "root"], + ["a", project.repoAddress], + ...[...recipients].map((recipient) => ["p", recipient]), + ...(mediaTags ?? []), + ]; + + const event = await signRelayEvent({ + kind, + content: body, + tags, + }); + + await relayClient.publishEvent( + event, + "Timed out posting issue comment.", + "Failed to post issue comment.", + ); +} diff --git a/desktop/src/features/pulse/lib/projectComments.ts b/desktop/src/features/pulse/lib/projectComments.ts index df1c45e59f..f03d218dde 100644 --- a/desktop/src/features/pulse/lib/projectComments.ts +++ b/desktop/src/features/pulse/lib/projectComments.ts @@ -4,11 +4,11 @@ import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; const REPO_ADDRESS_PREFIX = `${KIND_REPO_ANNOUNCEMENT}:`; /** - * Project issue/PR comments are published as kind:1 text notes (the relay - * does not register NIP-22 kind 1111) tagged with the repo's NIP-34 address - * (`a` = `30617::`). Without this filter they bleed into Pulse - * feeds as orphaned replies whose parent (a 1618/1621 git event, not a - * kind:1 note) can never be resolved. + * Project issue/PR comments are published as kind:1 text notes (or kind:9 + * when they @mention an agent — see projectCommentPublish.ts) tagged with + * the repo's NIP-34 address (`a` = `30617::`). Without this + * filter they bleed into Pulse feeds as orphaned replies whose parent (a + * 1618/1621 git event, not a kind:1 note) can never be resolved. */ export function isProjectComment(note: UserNote): boolean { return note.tags.some( From 13ddcbb93a9694fd9bf16a29db5493baefbb5507 Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 14:05:52 +0300 Subject: [PATCH 2/3] fix(relay): isolate mesh_demo echo tests under parallel cargo test Signed-off-by: Taksh Co-authored-by: Cursor --- crates/buzz-relay/src/api/mesh_demo.rs | 45 +++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/api/mesh_demo.rs b/crates/buzz-relay/src/api/mesh_demo.rs index 8649b97671..0f49ca42a5 100644 --- a/crates/buzz-relay/src/api/mesh_demo.rs +++ b/crates/buzz-relay/src/api/mesh_demo.rs @@ -151,13 +151,18 @@ mod tests { use axum::body::to_bytes; use buzz_relay_mesh::endpoint::MeshEndpoint; use buzz_relay_mesh::{ - InboundHandler, MeshDatagram, MeshError, MeshStream, MeshStreamFrame, RuntimeId, + InboundHandler, MeshDatagram, MeshError, MeshStream, MeshStreamFrame, Profile, RuntimeId, StreamHello, }; + use std::sync::Mutex; use uuid::Uuid; use super::*; - use crate::tunnel::directory::SessionDirectory; + use crate::tunnel::directory::{SessionDirectory, SessionLease}; + + /// Serialize mesh echo tests against shared Redis lease keys / local UDP + /// binds so parallel `cargo test -p buzz-relay --lib` does not flake (#2458). + static MESH_DEMO_IO_LOCK: Mutex<()> = Mutex::new(()); fn pool() -> deadpool_redis::Pool { let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); @@ -166,6 +171,26 @@ mod tests { .expect("create redis pool") } + async fn clear_keys(directory: &SessionDirectory, community_id: CommunityId, session_id: Uuid) { + let base = format!("buzz:{}:tunnel:{}", community_id, session_id); + let _ = directory + .release(&SessionLease { + community_id, + session_id, + owner_runtime_id: RuntimeId([1; 32]), + generation: 1, + profile: Profile::ReliableStream, + }) + .await; + let mut conn = pool().get().await.expect("redis conn"); + let _: () = redis::cmd("DEL") + .arg(format!("{base}:lease")) + .arg(format!("{base}:generation")) + .query_async(&mut *conn) + .await + .expect("clear keys"); + } + async fn redis_directory_if_available() -> Option { let pool = pool(); let mut conn = pool.get().await.ok()?; @@ -233,9 +258,15 @@ mod tests { /// First post for a session acquires the fenced lease and reports `owned`. #[tokio::test] async fn demo_join_owned_arm_reports_generation() { + let _guard = MESH_DEMO_IO_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let Some(directory) = redis_directory_if_available().await else { return; }; + let community_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + clear_keys(&directory, CommunityId::from_uuid(community_id), session_id).await; let router = ReliableStreamRouter::new( directory.clone(), std::sync::Arc::new(NoopTransport), @@ -245,8 +276,8 @@ mod tests { &router, &directory, DemoEchoRequest { - community_id: Uuid::new_v4(), - session_id: Uuid::new_v4(), + community_id, + session_id, payload: "unused".into(), }, ) @@ -255,6 +286,7 @@ mod tests { let body = body_json(resp).await; assert_eq!(body["outcome"], "owned"); assert!(body["generation"].as_u64().is_some()); + clear_keys(&directory, CommunityId::from_uuid(community_id), session_id).await; } /// Second runtime forwards to the owner and round-trips the payload @@ -262,11 +294,15 @@ mod tests { /// end to end over a real mesh stream pair. #[tokio::test] async fn demo_join_forwarded_arm_round_trips_echo() { + let _guard = MESH_DEMO_IO_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let Some(directory) = redis_directory_if_available().await else { return; }; let community_id = Uuid::new_v4(); let session_id = Uuid::new_v4(); + clear_keys(&directory, CommunityId::from_uuid(community_id), session_id).await; let bind = || "127.0.0.1:0".parse().unwrap(); let local_endpoint = MeshEndpoint::bind(bind()).await.unwrap(); @@ -341,5 +377,6 @@ mod tests { assert_eq!(body["outcome"], "forwarded"); assert_eq!(body["echoed_payload"], "mesh echo evidence"); owner_task.abort(); + clear_keys(&directory, CommunityId::from_uuid(community_id), session_id).await; } } From 7b99a2798ea23200df1161652251ab14535723f8 Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 14:05:52 +0300 Subject: [PATCH 3/3] fix(desktop): time out stuck Builderlab sign-in with a recovery path Signed-off-by: Taksh Co-authored-by: Cursor --- .../ui/HostedCommunityOnboarding.tsx | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx index 1152cd8fec..af1a3fe340 100644 --- a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx +++ b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx @@ -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; @@ -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((_, 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)); }); }; @@ -496,18 +515,30 @@ export function HostedCommunityOnboarding({ Waiting for your browser… ) : ( - +
+ + {error ? ( + + ) : null} +
)} {/* Quiet breadcrumb: Buzz itself is open source; this hosted relay is the one account-backed piece of the flow. */}

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.

) : !identity ? (