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
45 changes: 41 additions & 4 deletions crates/buzz-relay/src/api/mesh_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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<SessionDirectory> {
let pool = pool();
let mut conn = pool.get().await.ok()?;
Expand Down Expand Up @@ -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),
Expand All @@ -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(),
},
)
Expand All @@ -255,18 +286,23 @@ 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
/// through the owner-side echo consumer (`recv_validated` + `send_bytes`),
/// 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();
Expand Down Expand Up @@ -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;
}
}
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[][] };
Loading