feat(projects): unify navigation and reviewed channel requests

Open project homes consistently from channel and project routes, preserve file/work-item deep links, and let agents request owner-reviewed project channels.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Co-authored-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-22 21:52:36 -04:00
committed by Wrench
parent 7d6c4abcec
commit 07d2ce6ffb
42 changed files with 1550 additions and 243 deletions

View File

@@ -6,7 +6,8 @@ use serde::Serialize;
use crate::error::CliError;
const REQUEST_KIND: &str = "agent_management_request";
const AGENT_REQUEST_KIND: &str = "agent_management_request";
const PROJECT_CHANNEL_REQUEST_KIND: &str = "project_channel_request";
const MAX_NAME_CHARS: usize = 120;
const MAX_PROMPT_CHARS: usize = 20_000;
@@ -37,6 +38,20 @@ pub struct UpdateAgentDraft {
pub respond_to: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateProjectChannelDraft {
pub home_channel_id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub visibility: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_name: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ManagementRequest<T> {
@@ -88,6 +103,7 @@ fn build<T: Serialize>(
keys: &Keys,
owner: &PublicKey,
channel_id: String,
request_kind: &'static str,
action: &'static str,
request: T,
) -> Result<BuiltDraftRequest, CliError> {
@@ -95,13 +111,13 @@ fn build<T: Serialize>(
let payload = ObserverEvent {
seq: 0,
timestamp: chrono::Utc::now().to_rfc3339(),
kind: REQUEST_KIND,
kind: request_kind,
agent_index: None,
channel_id: Some(channel_id),
session_id: None,
turn_id: None,
payload: ManagementRequest {
request_type: REQUEST_KIND,
request_type: request_kind,
action,
request_id: request_id.clone(),
request,
@@ -138,7 +154,14 @@ pub fn build_create(
display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?,
system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?,
};
build(keys, owner, channel_id, "create", request)
build(
keys,
owner,
channel_id,
AGENT_REQUEST_KIND,
"create",
request,
)
}
pub fn build_update(
@@ -182,7 +205,50 @@ pub fn build_update(
"include at least one field to update".into(),
));
}
build(keys, owner, channel_id, "update", request)
build(
keys,
owner,
channel_id,
AGENT_REQUEST_KIND,
"update",
request,
)
}
pub fn build_project_channel(
keys: &Keys,
owner: &PublicKey,
draft: CreateProjectChannelDraft,
) -> Result<BuiltDraftRequest, CliError> {
let home_channel_id = required(draft.home_channel_id, "home channel", 128)?;
uuid::Uuid::parse_str(&home_channel_id)
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {home_channel_id}")))?;
let visibility = required(draft.visibility, "visibility", 16)?;
if visibility != "open" && visibility != "private" {
return Err(CliError::Usage("visibility must be open or private".into()));
}
if draft.ttl_seconds == Some(0) {
return Err(CliError::Usage("ttl must be greater than zero".into()));
}
let request = CreateProjectChannelDraft {
home_channel_id: home_channel_id.clone(),
name: required(draft.name, "name", MAX_NAME_CHARS)?,
description: draft
.description
.map(|value| required(value, "description", 2_048))
.transpose()?,
visibility,
ttl_seconds: draft.ttl_seconds,
template_name: optional(draft.template_name, "template")?,
};
build(
keys,
owner,
home_channel_id,
PROJECT_CHANNEL_REQUEST_KIND,
"create",
request,
)
}
#[cfg(test)]
@@ -228,9 +294,9 @@ mod tests {
.any(|tag| tag.first().map(String::as_str) == Some("h")));
let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
assert_eq!(payload["kind"], REQUEST_KIND);
assert_eq!(payload["kind"], AGENT_REQUEST_KIND);
assert_eq!(payload["channelId"], CHANNEL);
assert_eq!(payload["payload"]["type"], REQUEST_KIND);
assert_eq!(payload["payload"]["type"], AGENT_REQUEST_KIND);
assert_eq!(payload["payload"]["action"], "create");
assert_eq!(
payload["payload"]["request"]["displayName"],
@@ -274,4 +340,34 @@ mod tests {
.unwrap_err();
assert!(error.to_string().contains("invalid channel UUID"));
}
#[test]
fn project_channel_request_is_owner_encrypted() {
let agent = Keys::generate();
let owner = Keys::generate();
let built = build_project_channel(
&agent,
&owner.public_key(),
CreateProjectChannelDraft {
home_channel_id: CHANNEL.into(),
name: "release-planning".into(),
description: Some("Coordinate the next release.".into()),
visibility: "open".into(),
ttl_seconds: None,
template_name: Some("Release team".into()),
},
)
.unwrap();
let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
assert_eq!(payload["kind"], PROJECT_CHANNEL_REQUEST_KIND);
assert_eq!(payload["channelId"], CHANNEL);
assert_eq!(payload["payload"]["type"], PROJECT_CHANNEL_REQUEST_KIND);
assert_eq!(payload["payload"]["action"], "create");
assert_eq!(payload["payload"]["request"]["homeChannelId"], CHANNEL);
assert_eq!(
payload["payload"]["request"]["templateName"],
"Release team"
);
}
}

View File

@@ -21,14 +21,58 @@ use buzz_sdk::{
build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord,
PROJECT_D_MAX_LEN,
};
use nostr::{Event, EventBuilder, Tag, Timestamp};
use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp};
use crate::agent_management::{build_project_channel, CreateProjectChannelDraft};
use crate::client::BuzzClient;
use crate::commands::parse_write_response;
use crate::commands::project_channel::repo_id_from_project_slug;
use crate::commands::repos::{build_create_announcement, fetch_own_repo_announcement};
use crate::error::CliError;
async fn cmd_add_channel_draft(
client: &BuzzClient,
home_channel: String,
name: String,
description: Option<String>,
visibility: String,
ttl_seconds: Option<u64>,
template_name: Option<String>,
) -> Result<(), CliError> {
let owner_hex = client
.auth_tag_owner_hex()
.ok_or_else(|| CliError::Auth("project channel requests require BUZZ_AUTH_TAG".into()))?;
let owner = PublicKey::parse(&owner_hex)
.map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?;
let built = build_project_channel(
client.keys(),
&owner,
CreateProjectChannelDraft {
home_channel_id: home_channel,
name,
description,
visibility,
ttl_seconds,
template_name,
},
)?;
let response = client.publish_ephemeral_event(built.event).await?;
let mut output: serde_json::Value = serde_json::from_str(&response)
.map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?;
if let Some(object) = output.as_object_mut() {
object.insert("request_id".into(), built.request_id.into());
object.insert("action".into(), "add-channel".into());
object.insert("saved".into(), false.into());
object.insert(
"message".into(),
"Project channel draft sent to Buzz Desktop for owner review. The channel is not created until the owner approves it."
.into(),
);
}
println!("{output}");
Ok(())
}
// ── Buzz repo-ID grammar (bare --repo shorthand) ─────────────────────────────
/// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand).
@@ -739,6 +783,25 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<()
ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await,
ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await,
ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await,
ProjectsCmd::AddChannel {
home_channel,
name,
description,
visibility,
ttl,
template,
} => {
cmd_add_channel_draft(
client,
home_channel,
name,
description,
visibility.to_string(),
ttl,
template,
)
.await
}
ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await,
ProjectsCmd::Update {
slug,

View File

@@ -1334,6 +1334,28 @@ pub enum ProjectsCmd {
#[arg(long = "repo", required = true)]
repo: Vec<String>,
},
/// Draft a project-linked channel for owner review in Buzz Desktop
#[command(name = "add-channel")]
AddChannel {
/// Project home channel UUID from the current ACP [Context]
#[arg(long)]
home_channel: String,
/// New channel name
#[arg(long)]
name: String,
/// Optional channel description
#[arg(long)]
description: Option<String>,
/// Channel visibility
#[arg(long, value_enum, default_value = "open")]
visibility: ChannelVisibility,
/// Optional temporary-channel lifetime in seconds
#[arg(long)]
ttl: Option<u64>,
/// Optional Desktop channel-template name
#[arg(long)]
template: Option<String>,
},
/// Remove one or more member repositories from a project
#[command(name = "remove-repo")]
RemoveRepo {
@@ -2375,6 +2397,7 @@ mod tests {
assert_eq!(
names(&cmd, "projects"),
vec![
"add-channel",
"add-repo",
"create",
"delete",
@@ -2421,7 +2444,7 @@ mod tests {
("pack", 2),
("patches", 4),
("pr", 5),
("projects", 7),
("projects", 8),
("reactions", 3),
("repos", 5),
("social", 7),
@@ -2492,6 +2515,25 @@ mod tests {
// ── projects update mutation group ────────────────────────────────────────
/// Project-channel requests accept the owner-review metadata.
#[test]
fn projects_add_channel_accepts_owner_review_fields() {
assert!(Cli::try_parse_from([
"buzz",
"projects",
"add-channel",
"--home-channel",
"11111111-1111-4111-8111-111111111111",
"--name",
"release-planning",
"--visibility",
"private",
"--template",
"Release team",
])
.is_ok());
}
/// Multiple independent fields must be accepted in the same invocation.
#[test]
fn projects_update_multi_field_is_accepted() {