Skip to content
Draft
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
3 changes: 3 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ the interface unless an available tool actually supports that action.

- Plugins can add features. **Settings → Plugins → Manage plugins** controls
them. Do not assume a particular optional plugin is on.
- A plugin can manage some Solo chats, such as task chats. For those chats,
**Archive**, **Unarchive**, and **Undo** open the plugin's own flow. If that
plugin is off or still loading, Buzz refuses the action and changes nothing.
- **Attention**, when available, lets agents use Interests, watches, and timers
to follow work. Its tab in an agent's profile shows what that agent is watching
and whether Attention is on for this device. A mention can still wake an agent
Expand Down
8 changes: 8 additions & 0 deletions docs/solo-regions.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ tasks and restore focus after a move. Plugins must scope queries to their own
region. Changes to the marker or its meaning require coordinated updates to the
host and consuming plugins.

A source may own Archive, Unarchive, and Undo for its chats through the optional
`lifecycle(binding, { channelId, action })` hook. The host routes these actions
for every Solo chat with the source's effective region label, archived chats
included. It then opens no confirmation and publishes nothing; a rejection shows
as the action's failure. If the source is off, loading, unavailable, or failed,
or has no hook, the host refuses the action and publishes nothing. Unlabelled
Solo chats and other actions keep the host confirmation.

The host retains one JSON state value per scope, viewer, and source key across
unmount, disposal, and registration. It accepts at most 16 KiB. A failed or stale
write returns false and does not update the memory value. Sources must keep their
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ fn request_url(community: &str, path: &str, method: &str) -> Result<Url> {
| "/api/invites/accept-policy"
| "/gifs/search"
| "/profiles/search"
| "/buzz/v1/lifecycle/state"
),
_ => false,
};
Expand Down
83 changes: 71 additions & 12 deletions src-tauri/src/relay/channel_writes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,27 @@ fn details_labels(labels: &[Vec<String>]) -> bool {
fn lifecycle(event: &EventTemplate) -> bool {
if !matches!(event.kind, 9002 | 9008 | 9021 | 9022 | 41012)
|| !common(event)
|| event.tags.len() != if event.kind == 9002 { 2 } else { 1 }
|| !tag(event, 0, "h").is_some_and(uuid)
{
return false;
}
tag(event, 0, "h").is_some_and(uuid)
&& (event.kind != 9002 || matches!(tag(event, 1, "archived"), Some("true" | "false")))
match (event.kind, event.tags.len()) {
(9002, 2) => matches!(tag(event, 1, "archived"), Some("true" | "false")),
// Guarded state request: archive is always subtree-scoped.
(9002, 4) => {
matches!(
(tag(event, 1, "archived"), tag(event, 2, "scope")),
(Some("true"), Some("subtree")) | (Some("false"), Some("target" | "subtree"))
) && tag(event, 3, "expect").is_some_and(super::hex_key)
}
// Guarded fence: replaces only the target's token.
(9002, 3) => {
tag(event, 1, "lifecycle") == Some("fence")
&& tag(event, 2, "expect-target").is_some_and(|t| t == "-" || uuid(t))
}
(9002, _) => false,
(_, len) => len == 1,
}
}
fn archive(event: &EventTemplate) -> bool {
if !matches!(event.kind, 9035 | 9036)
Expand Down Expand Up @@ -878,12 +893,10 @@ mod member_administration_tests {
use super::*;
use tauri::test::{get_ipc_response, mock_builder, INVOKE_KEY};

#[test]
fn member_commands_match_js_and_sign_through_production_ipc_and_acl() {
let cases: Value = serde_json::from_str(include_str!(
"../../../src/features/channel-members/administration-contract.json"
))
.unwrap();
/// A production command handler for one purpose-bound route.
fn ipc(
route: &'static str,
) -> impl Fn(&str, Value) -> std::result::Result<tauri::ipc::InvokeResponseBody, Value> {
let app = mock_builder()
.manage(IdentityHost::fixture())
.manage(FederatedIdentity::new().unwrap())
Expand All @@ -893,7 +906,8 @@ mod member_administration_tests {
let view = tauri::WebviewWindowBuilder::new(&app, "main", tauri::WebviewUrl::default())
.build()
.unwrap();
let invoke = |command: &str, event: Value| {
move |command: &str, event: Value| {
let _app = &app;
get_ipc_response(
&view,
tauri::webview::InvokeRequest {
Expand All @@ -902,13 +916,58 @@ mod member_administration_tests {
error: tauri::ipc::CallbackFn(1),
url: view.url().unwrap(),
body: tauri::ipc::InvokeBody::Json(serde_json::json!({
"community": "https://relay.test", "route": "member-administration", "event": event
"community": "https://relay.test", "route": route, "event": event
})),
headers: Default::default(),
invoke_key: INVOKE_KEY.into(),
},
)
};
}
}

#[test]
fn guarded_lifecycle_commands_match_js_and_sign_through_production_ipc() {
let cases: Value = serde_json::from_str(include_str!(
"../../../src/features/relay/channel-lifecycle-contract.json"
))
.unwrap();
let invoke = ipc("channel-lifecycle");
for case in cases["accepted"].as_array().unwrap() {
let template = case["event"].clone();
let event: Value = invoke("relay_channel_sign", template.clone())
.unwrap()
.deserialize()
.unwrap();
super::super::verify_signature(&event).unwrap();
assert_eq!(event["tags"], template["tags"], "{}", case["name"]);
let viewer = event["pubkey"].as_str().unwrap();
assert!(validate("channel-lifecycle", &event, Some(viewer)).is_ok());
for route in [
"channel-details",
"identity-archive",
"member-administration",
] {
assert!(validate(route, &event, Some(viewer)).is_err());
}
}
for case in cases["rejected"].as_array().unwrap() {
let event = case["event"].clone();
assert!(
validate("channel-lifecycle", &event, None).is_err(),
"{}",
case["name"]
);
assert!(invoke("relay_channel_sign", event).is_err());
}
}

#[test]
fn member_commands_match_js_and_sign_through_production_ipc_and_acl() {
let cases: Value = serde_json::from_str(include_str!(
"../../../src/features/channel-members/administration-contract.json"
))
.unwrap();
let invoke = ipc("member-administration");
for case in cases["accepted"].as_array().unwrap() {
let template = case["event"].clone();
let event: Value = invoke("relay_channel_sign", template.clone())
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/src/relay/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ fn routes_cannot_retarget_credentials_or_expand_http_access() {
] {
assert!(request_url("https://relay.test", path, "POST").is_err());
}
for path in ["/api/invites", "/gifs/search", "/profiles/search"] {
for path in [
"/api/invites",
"/gifs/search",
"/profiles/search",
"/buzz/v1/lifecycle/state",
] {
assert!(request_url("https://relay.test", path, "POST").is_ok());
assert!(request_url("https://relay.test", path, "GET").is_err());
}
Expand Down
48 changes: 48 additions & 0 deletions src/features/channel-navigation/ChannelNavigationState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,51 @@ it("runs a chat archive's Undo through the lifecycle gate, after any action hold
expect(view.result.current.lifecycleDialog).toBeUndefined();
expect(run).toHaveBeenCalledTimes(3);
});

it("refuses a region chat's Archive, Unarchive and Undo without a live source and publishes nothing", async () => {
const run = vi.fn(async () => {});
const view = mount(fixture(run).relay, true);
const task = {
id: "task",
name: "Task",
channelType: "solo" as const,
labels: ["buzz.solo", "buzz.region:nexus:tasks"],
};
for (const [action, choose] of [
["Archive", () => view.result.current.openLifecycle(task, "archive")],
[
"Unarchive",
() =>
view.result.current.openLifecycle(
{ ...task, archived: true },
"unarchive",
),
],
[
"Unarchive",
() => view.result.current.undoArchive({ ...task, archived: true }),
],
] as const) {
act(choose);
expect(view.result.current.lifecycleDialog).toBeUndefined();
expect(
await screen.findByText(
"The plugin that manages this chat is not available. Try again when it is running.",
),
).toBeDefined();
expect(screen.getByText(`${action} chat failed`)).toBeDefined();
fireEvent.click(
screen.getByRole("button", { name: "Dismiss notification" }),
);
await waitFor(() =>
expect(screen.queryByText(`${action} chat failed`)).toBeNull(),
);
}
expect(run).not.toHaveBeenCalled();
// Leave and Delete are not region-owned and keep the host confirmation.
act(() => view.result.current.openLifecycle(task, "leave"));
expect(view.result.current.lifecycleDialog).toMatchObject({
channel: task,
action: "leave",
});
});
22 changes: 22 additions & 0 deletions src/features/channel-navigation/ChannelNavigationState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { RelayData } from "../relay/service";
import type { RelaySession } from "../relay/session";
import type { ChannelSummary } from "../relay/contracts";
import type { ChannelLifecycleAction } from "../relay/channel-lifecycle-protocol";
import { routeSoloLifecycle } from "./solo-navigation";

/** The session owns writes/recovery; each menu owns its focus and read status. */
export type ChannelMenuSurface = {
Expand Down Expand Up @@ -212,6 +213,24 @@ export function ChannelNavigationProvider({
else lifecycleOrigin.addEventListener("abort", retire, { once: true });
return () => lifecycleOrigin.removeEventListener("abort", retire);
}, [lifecycleOrigin, update]);
// A region source owns its chats' Archive, Unarchive and Undo: no dialog here.
const routeRegion = useCallback(
(channel: ChannelSummary, action: ChannelLifecycleAction) => {
if (action !== "archive" && action !== "unarchive") return false;
const session = connection.session;
const routed = routeSoloLifecycle(session, channel, action);
routed?.catch((error: unknown) => {
if (relay.snapshot().session === session)
setFailure({
session,
title: `${action === "archive" ? "Archive" : "Unarchive"} chat failed`,
message: error instanceof Error ? error.message : String(error),
});
});
return !!routed;
},
[relay, connection.session],
);
const value = useMemo<Handoff>(
() => ({
...state,
Expand All @@ -238,6 +257,7 @@ export function ChannelNavigationProvider({
}));
},
openLifecycle(channel, action, trigger, origin, callbacks) {
if (origin?.aborted || routeRegion(channel, action)) return;
update((previous) =>
origin?.aborted || previous.lifecycleDialog
? previous
Expand Down Expand Up @@ -283,6 +303,7 @@ export function ChannelNavigationProvider({
update((previous) => ({ ...previous, lifecycleDialog: undefined }));
},
undoArchive(channel) {
if (routeRegion(channel, "unarchive")) return;
update((previous) =>
previous.lifecycleDialog
? {
Expand Down Expand Up @@ -311,6 +332,7 @@ export function ChannelNavigationProvider({
connection.session,
connection.viewer,
update,
routeRegion,
clearPreparingDm,
],
);
Expand Down
67 changes: 59 additions & 8 deletions src/features/channel-navigation/SoloRegions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { afterEach, assert, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { type ReactNode, useCallback, useRef, useState } from "react";
import { SoloRegions } from "./SoloRegions";
import { ChannelNavigationProvider } from "./ChannelNavigationState";
import { ToastProvider } from "../../shared/design-system/ui/Toast";
import { MenuItem } from "../../shared/design-system/ui/Menu";
import { SoloHeading } from "../../bundled/channels/SoloHeading";
Expand Down Expand Up @@ -44,6 +45,7 @@ function fixture() {
function mount(
h: ReturnType<typeof fixture>,
target: OpenTarget = { version: 1, kind: "home" },
handoff = false,
) {
const open = vi.fn(async () => ({ status: "opened" as const }));
const retryReveal = vi.fn();
Expand Down Expand Up @@ -81,11 +83,18 @@ function mount(
/>
);
}
const mounted = render(
const Tree = () => (
<ToastProvider>
<View />
</ToastProvider>,
{handoff ? (
<ChannelNavigationProvider relay={h.relay}>
<View />
</ChannelNavigationProvider>
) : (
<View />
)}
</ToastProvider>
);
const mounted = render(<Tree />);
return {
...mounted,
open,
Expand All @@ -94,11 +103,7 @@ function mount(
navigate(next: OpenTarget) {
target = next;
void h.navigate(next);
mounted.rerender(
<ToastProvider>
<View />
</ToastProvider>,
);
mounted.rerender(<Tree />);
},
};
}
Expand Down Expand Up @@ -1461,3 +1466,49 @@ it("hands a header menu action the durable trigger as its dialog origin", async
);
expect(selected).toHaveBeenCalledOnce();
});

it("sends the chat menu's Archive for a region chat to the source hook instead of the host dialog", async () => {
const h = fixture();
const run = vi.fn(async () => {});
Object.assign(h.session, {
channelLifecycle: {
...h.session.channelLifecycle,
available: true,
run,
load: async (channelId: string) => ({
channelId,
channelType: "stream",
canArchive: true,
canUnarchive: false,
canDelete: false,
canLeave: true,
canHide: false,
}),
},
});
const lifecycle = vi.fn(async () => {
throw new Error("Finish preparing this task first.");
});
const source = await h.register({ component: Body, lifecycle });
mount(h, undefined, true);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Actions for Owned" }));
await user.click(
await screen.findByRole("menuitem", { name: "Archive chat" }),
);
await waitFor(() =>
expect(lifecycle).toHaveBeenCalledExactlyOnceWith(source.binding(), {
channelId: "owned",
action: "archive",
}),
);
// The source owns confirmation and results; its refusal is the action's failure.
expect(
await screen.findByText("Finish preparing this task first."),
).toBeInTheDocument();
expect(screen.getByText("Archive chat failed")).toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "Archive chat" }),
).not.toBeInTheDocument();
expect(run).not.toHaveBeenCalled();
});
7 changes: 7 additions & 0 deletions src/features/channel-navigation/solo-navigation-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export type SoloRegionSource = Readonly<{
read(binding: SoloRegionBinding): SoloRegionSnapshot;
component: ComponentType<SoloRegionProps>;
actions?: (binding: SoloRegionBinding) => readonly SoloRegionAction[];
/** Owns Archive, Unarchive and Undo for this region's chats, archived ones
* included. The host then shows no confirmation, result or Undo and publishes
* nothing; a rejection shows as the action's failure. */
lifecycle?: (
binding: SoloRegionBinding,
request: Readonly<{ channelId: string; action: "archive" | "unarchive" }>,
) => void | Promise<void>;
}>;
export type SoloChatInput = Readonly<{
channelId: string;
Expand Down
Loading
Loading