From 94a6194044b2db1afcc97f972acfea13060eb151 Mon Sep 17 00:00:00 2001 From: NamPhan Date: Mon, 17 Aug 2026 16:13:04 +0700 Subject: [PATCH] =?UTF-8?q?feat(hive):=20add=20Flow=20Studio=20and=20Agent?= =?UTF-8?q?=20Studio=20(Buzz=20Hive=20P0=E2=80=93P5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship buzz-flow and buzz-agent-studio with Nostr kinds 462xx/472xx, pgvector migration 0032, relay HTTP surfaces and projector, WF-08 approval suspend/resume, desktop canvas and agent graph UIs, session/block cost telemetry, and docs/E2E smoke. Signed-off-by: NamPhan --- ARCHITECTURE.md | 22 +- Cargo.lock | 25 + Cargo.toml | 4 + NOTICE | 22 + VISION.md | 15 +- crates/buzz-acp/Cargo.toml | 1 + crates/buzz-acp/src/pool.rs | 72 +++ crates/buzz-agent-studio/Cargo.toml | 14 + crates/buzz-agent-studio/src/events.rs | 110 ++++ crates/buzz-agent-studio/src/graph.rs | 300 ++++++++++ crates/buzz-agent-studio/src/graph_events.rs | 82 +++ crates/buzz-agent-studio/src/graph_loader.rs | 93 ++++ crates/buzz-agent-studio/src/lib.rs | 13 + crates/buzz-agent-studio/src/monitor.rs | 84 +++ crates/buzz-agent-studio/src/skill_import.rs | 96 ++++ crates/buzz-core/src/kind.rs | 76 ++- crates/buzz-db/src/flow_studio.rs | 516 ++++++++++++++++++ crates/buzz-db/src/lib.rs | 156 ++++++ crates/buzz-db/src/migration.rs | 18 +- crates/buzz-flow/Cargo.toml | 18 + crates/buzz-flow/src/blocks/mod.rs | 85 +++ crates/buzz-flow/src/event_payloads.rs | 134 +++++ crates/buzz-flow/src/events.rs | 44 ++ crates/buzz-flow/src/files.rs | 74 +++ crates/buzz-flow/src/knowledge/embed.rs | 80 +++ crates/buzz-flow/src/knowledge/mod.rs | 68 +++ crates/buzz-flow/src/lib.rs | 16 + crates/buzz-flow/src/projector.rs | 132 +++++ crates/buzz-flow/src/tables.rs | 51 ++ crates/buzz-flow/src/tools/mod.rs | 50 ++ crates/buzz-flow/src/workflow_bridge.rs | 149 +++++ crates/buzz-relay/Cargo.toml | 2 + crates/buzz-relay/src/api/agent_studio.rs | 246 +++++++++ crates/buzz-relay/src/api/bridge.rs | 4 + crates/buzz-relay/src/api/flow_studio.rs | 325 +++++++++++ crates/buzz-relay/src/api/mod.rs | 2 + crates/buzz-relay/src/flow_telemetry.rs | 157 ++++++ .../src/handlers/command_executor.rs | 22 +- crates/buzz-relay/src/handlers/event.rs | 121 ++++ crates/buzz-relay/src/handlers/ingest.rs | 5 + crates/buzz-relay/src/lib.rs | 2 + crates/buzz-relay/src/router.rs | 31 ++ crates/buzz-workflow/src/executor.rs | 53 +- crates/buzz-workflow/src/lib.rs | 73 ++- crates/buzz-workflow/src/schema.rs | 6 + desktop/package.json | 1 + desktop/playwright.config.ts | 1 + desktop/src-tauri/src/commands/hive_studio.rs | 153 ++++++ desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/personas/pending.rs | 33 +- desktop/src-tauri/src/events.rs | 25 +- desktop/src-tauri/src/events/canvas.rs | 27 + desktop/src-tauri/src/events/hive_studio.rs | 142 +++++ desktop/src-tauri/src/lib.rs | 17 +- .../src/managed_agents/agent_studio_events.rs | 35 ++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + desktop/src/app/AppShell.helpers.ts | 18 +- desktop/src/app/AppShell.tsx | 4 + .../src/app/navigation/useAppNavigation.ts | 14 + desktop/src/app/routeTree.gen.ts | 42 ++ desktop/src/app/routes.ts | 2 + desktop/src/app/routes/agent-studio.tsx | 23 + desktop/src/app/routes/flow-studio.tsx | 23 + .../features/agent-studio/ui/AgentGraph.tsx | 81 +++ .../agent-studio/ui/AgentStudioScreen.tsx | 9 + .../agent-studio/ui/AgentStudioView.tsx | 127 +++++ .../agent-studio/ui/SessionMonitor.tsx | 123 +++++ .../agent-studio/ui/SkillImportModal.tsx | 127 +++++ .../agent-studio/ui/UnifiedCostMonitor.tsx | 116 ++++ .../features/flow-studio/ui/BlockPalette.tsx | 40 ++ .../src/features/flow-studio/ui/Canvas.tsx | 133 +++++ .../features/flow-studio/ui/FilesPanel.tsx | 116 ++++ .../flow-studio/ui/FlowStudioScreen.tsx | 9 + .../flow-studio/ui/FlowStudioView.tsx | 265 +++++++++ .../flow-studio/ui/KnowledgeBasePanel.tsx | 120 ++++ .../features/flow-studio/ui/TablesPanel.tsx | 124 +++++ .../src/features/sidebar/ui/AppSidebar.tsx | 16 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 48 +- .../workflows/ui/WorkflowApprovalCard.tsx | 35 +- .../workflows/ui/WorkflowRunTrace.tsx | 1 + desktop/src/shared/api/tauriHiveStudio.ts | 80 +++ desktop/src/shared/constants/kinds.ts | 10 + desktop/src/shared/ui/ViewLoadingFallback.tsx | 6 +- desktop/tests/e2e/hive-studio.spec.ts | 69 +++ docker-compose.harness.yml | 2 +- docker-compose.yml | 2 +- docs/BUZZ_HIVE_IMPLEMENTATION_PLAN.md | 225 ++++++++ docs/BUZZ_HIVE_MERGE_SPEC.md | 225 ++++++++ docs/DB_AUDIT.md | 42 ++ docs/MERGE_NOTES.md | 50 ++ docs/WF-08.md | 45 ++ migrations/0032_buzz_hive_studio.sql | 61 +++ pnpm-lock.yaml | 184 +++++++ preview-features.json | 12 + schema/schema.sql | 61 +++ 95 files changed, 6723 insertions(+), 73 deletions(-) create mode 100644 NOTICE create mode 100644 crates/buzz-agent-studio/Cargo.toml create mode 100644 crates/buzz-agent-studio/src/events.rs create mode 100644 crates/buzz-agent-studio/src/graph.rs create mode 100644 crates/buzz-agent-studio/src/graph_events.rs create mode 100644 crates/buzz-agent-studio/src/graph_loader.rs create mode 100644 crates/buzz-agent-studio/src/lib.rs create mode 100644 crates/buzz-agent-studio/src/monitor.rs create mode 100644 crates/buzz-agent-studio/src/skill_import.rs create mode 100644 crates/buzz-db/src/flow_studio.rs create mode 100644 crates/buzz-flow/Cargo.toml create mode 100644 crates/buzz-flow/src/blocks/mod.rs create mode 100644 crates/buzz-flow/src/event_payloads.rs create mode 100644 crates/buzz-flow/src/events.rs create mode 100644 crates/buzz-flow/src/files.rs create mode 100644 crates/buzz-flow/src/knowledge/embed.rs create mode 100644 crates/buzz-flow/src/knowledge/mod.rs create mode 100644 crates/buzz-flow/src/lib.rs create mode 100644 crates/buzz-flow/src/projector.rs create mode 100644 crates/buzz-flow/src/tables.rs create mode 100644 crates/buzz-flow/src/tools/mod.rs create mode 100644 crates/buzz-flow/src/workflow_bridge.rs create mode 100644 crates/buzz-relay/src/api/agent_studio.rs create mode 100644 crates/buzz-relay/src/api/flow_studio.rs create mode 100644 crates/buzz-relay/src/flow_telemetry.rs create mode 100644 desktop/src-tauri/src/commands/hive_studio.rs create mode 100644 desktop/src-tauri/src/events/canvas.rs create mode 100644 desktop/src-tauri/src/events/hive_studio.rs create mode 100644 desktop/src-tauri/src/managed_agents/agent_studio_events.rs create mode 100644 desktop/src/app/routes/agent-studio.tsx create mode 100644 desktop/src/app/routes/flow-studio.tsx create mode 100644 desktop/src/features/agent-studio/ui/AgentGraph.tsx create mode 100644 desktop/src/features/agent-studio/ui/AgentStudioScreen.tsx create mode 100644 desktop/src/features/agent-studio/ui/AgentStudioView.tsx create mode 100644 desktop/src/features/agent-studio/ui/SessionMonitor.tsx create mode 100644 desktop/src/features/agent-studio/ui/SkillImportModal.tsx create mode 100644 desktop/src/features/agent-studio/ui/UnifiedCostMonitor.tsx create mode 100644 desktop/src/features/flow-studio/ui/BlockPalette.tsx create mode 100644 desktop/src/features/flow-studio/ui/Canvas.tsx create mode 100644 desktop/src/features/flow-studio/ui/FilesPanel.tsx create mode 100644 desktop/src/features/flow-studio/ui/FlowStudioScreen.tsx create mode 100644 desktop/src/features/flow-studio/ui/FlowStudioView.tsx create mode 100644 desktop/src/features/flow-studio/ui/KnowledgeBasePanel.tsx create mode 100644 desktop/src/features/flow-studio/ui/TablesPanel.tsx create mode 100644 desktop/src/shared/api/tauriHiveStudio.ts create mode 100644 desktop/tests/e2e/hive-studio.spec.ts create mode 100644 docs/BUZZ_HIVE_IMPLEMENTATION_PLAN.md create mode 100644 docs/BUZZ_HIVE_MERGE_SPEC.md create mode 100644 docs/DB_AUDIT.md create mode 100644 docs/MERGE_NOTES.md create mode 100644 docs/WF-08.md create mode 100644 migrations/0032_buzz_hive_studio.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 892082d96c6..00a8cca616f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,9 @@ buzz-core (zero I/O — types, verification, filter matching, kind registry) ├── buzz-pubsub (Redis pub/sub, presence, typing indicators) ├── buzz-search (Postgres FTS: query, delete) ├── buzz-audit (hash-chain tamper-evident log) - └── buzz-workflow (YAML-as-code automation engine) + ├── buzz-workflow (YAML-as-code automation engine) + ├── buzz-flow (Flow Studio — canvas blocks, KB, tables, files; kinds 46200–46399) + └── buzz-agent-studio (Agent Studio — graph, skills, telemetry; kinds 47200–47399) │ └── buzz-relay (ties everything together — the server) @@ -506,6 +508,24 @@ Tamper-evident append-only log with SHA-256 hash chaining. --- +### buzz-flow — Flow Studio (Buzz Hive) + +Visual workflow canvas, block/tool registry, knowledge base, tables, and file metadata. All writes are Nostr events (kinds 46200–46399); Postgres tables in migration `0032` are a read-model projector target. + +**HTTP surface (relay):** `/flow-studio/blocks`, `/flow-studio/graph`, `/flow-studio/knowledge/search`, `/flow-studio/tables/{id}/rows`, `/flow-studio/files`, YAML export from canvas. + +**Projector:** `buzz-flow/src/projector.rs` — ingest hook in `buzz-relay` applies KB docs, table rows, and file metadata to Postgres. + +--- + +### buzz-agent-studio — Agent Studio (Buzz Hive) + +Agent/skill dependency graph, GitHub skill import planning, and ACP session telemetry (kind 47300). Persona publishes also emit kind 47200/47201. + +**HTTP surface:** `/agent-studio/graph`, `/agent-studio/sessions`, `/agent-studio/costs`, `/agent-studio/skills/import`. + +--- + ### buzz-workflow — YAML-as-Code Automation Engine Parses, validates, and executes channel-scoped workflow definitions. In multi-community mode workflow definitions, runs, approvals, webhook routes, and schedules inherit the host-derived community and evaluate triggers only against events in that community. diff --git a/Cargo.lock b/Cargo.lock index 6c46beedf2f..f83e2a8143b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -830,6 +830,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64 0.22.1", + "buzz-agent-studio", "buzz-core", "buzz-persona", "buzz-sdk", @@ -911,6 +912,16 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "buzz-agent-studio" +version = "0.1.0" +dependencies = [ + "buzz-core", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "buzz-audit" version = "0.1.0" @@ -1119,6 +1130,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "buzz-flow" +version = "0.1.0" +dependencies = [ + "buzz-core", + "buzz-workflow", + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "buzz-media" version = "0.1.0" @@ -1257,6 +1280,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "buzz-agent-studio", "buzz-audit", "buzz-auth", "buzz-conformance", @@ -1264,6 +1288,7 @@ dependencies = [ "buzz-datastore-tracing", "buzz-db", "buzz-deletion", + "buzz-flow", "buzz-media", "buzz-pubsub", "buzz-relay-mesh", diff --git a/Cargo.toml b/Cargo.toml index 78816ff4827..e7431f74c0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ members = [ "crates/buzz-admin", "crates/buzz-deletion", "crates/buzz-workflow", + "crates/buzz-flow", + "crates/buzz-agent-studio", "crates/buzz-media", "crates/buzz-cli", "crates/buzz-pairing-cli", @@ -142,6 +144,8 @@ buzz-pubsub = { path = "crates/buzz-pubsub" } buzz-search = { path = "crates/buzz-search" } buzz-audit = { path = "crates/buzz-audit" } buzz-workflow = { path = "crates/buzz-workflow" } +buzz-flow = { path = "crates/buzz-flow" } +buzz-agent-studio = { path = "crates/buzz-agent-studio" } buzz-media = { path = "crates/buzz-media" } buzz-sdk = { path = "crates/buzz-sdk" } buzz-ws-client = { path = "crates/buzz-ws-client" } diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000000..728e4d1ad05 --- /dev/null +++ b/NOTICE @@ -0,0 +1,22 @@ +Buzz +Copyright contributors to the Buzz project. + +Licensed under the Apache License, Version 2.0. + +--- + +## Third-party components (Buzz Hive ports) + +### claude-code-cli-ui (Agent Studio) + +- Upstream: https://github.com/Ngxba/claude-code-cli-ui +- License: MIT +- Ported to: `crates/buzz-agent-studio`, `desktop/src/features/agent-studio` +- Copyright remains with original authors; see upstream LICENSE. + +### Sim (Flow Studio) + +- Upstream: https://github.com/simstudioai/sim +- License: Apache-2.0 (verify upstream LICENSE before release) +- Ported to: `crates/buzz-flow`, `desktop/src/features/flow-studio` +- Reference only — not shipped as Next.js runtime. diff --git a/VISION.md b/VISION.md index 900e5a9475b..f649e8282bb 100644 --- a/VISION.md +++ b/VISION.md @@ -20,6 +20,8 @@ One community is your entire workspace. Work, conversation, agents, automation, | ✉️ **DMs** | 1:1 and group. Up to 9. | URGENT only | | 🤖 **Agents** | Directory. Your agents. Job board. | — | | ⚡ **Workflows** | YAML-as-code automation. Traces. | Approvals only | +| 🧩 **Flow Studio** | Visual workflow canvas (Buzz Hive). | Approvals only | +| 🤖 **Agent Studio** | Agent graph, skill import, cost monitor (Buzz Hive). | — | | 🔍 **Search** | Cmd+K. Instant. Full-text. | — | *Desktop app supports all seven surfaces today.* @@ -122,7 +124,18 @@ Relay communities can pool opted-in member hardware into shared AI compute. Exis Channel-scoped YAML-as-code automation with conditional logic — the feature Slack paywalled for 5 years. Message triggers, reaction triggers, scheduled runs, webhooks. Every step traced. Agents manage workflows through MCP tools. -Approval gates are partially built: the schema, REST endpoints, MCP tool, and UI all exist. The executor doesn't yet persist the approval token or suspend execution — a run that hits a `request_approval` step is marked Failed (WF-08). The infrastructure is there; the wiring is next. +Approval gates suspend execution at `request_approval` steps, persist an approval token, and resume after grant — wired end-to-end for Flow Studio human-approval blocks (WF-08). + +--- + +## Buzz Hive (Flow Studio + Agent Studio) + +Visual workflow builder and agent tooling on the same Nostr event log as everything else: + +- **Flow Studio** — canvas blocks → YAML → `buzz-workflow` runs; knowledge base (pgvector), tables, and files projected to Postgres; block execution emits kind 46201 for cost rollup. +- **Agent Studio** — persona/skill dependency graph, GitHub skill import, unified session cost monitor (ACP telemetry kind 47300 + flow blocks). + +Both surfaces are preview features in the desktop app (`flow-studio`, `agent-studio`). Spec: `docs/BUZZ_HIVE_MERGE_SPEC.md`. --- diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806f..fb3ac1a52f9 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -19,6 +19,7 @@ path = "src/main.rs" # Internal buzz-core = { workspace = true } buzz-sdk = { workspace = true } +buzz-agent-studio = { workspace = true } buzz-persona = { path = "../buzz-persona" } # Nostr diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2efacce2b19..e53ca696305 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -4177,6 +4177,78 @@ async fn publish_agent_turn_metric( "NIP-AM: publish timed out" ), } + publish_agent_session_telemetry(ctx, &usage, session_id).await; +} + +/// Best-effort: publish kind 47300 session telemetry for Agent Studio monitor. +async fn publish_agent_session_telemetry( + ctx: &PromptContext, + usage: &crate::usage::TurnUsage, + session_id: &str, +) { + use buzz_agent_studio::events::AgentSessionTelemetry; + use nostr::{EventBuilder, Kind, Tag}; + + let Some(owner_pk) = ctx.agent_owner_pubkey.as_ref() else { + return; + }; + + let payload = AgentSessionTelemetry { + session_id: session_id.to_string(), + agent_id: Some(ctx.harness_name.clone()), + input_tokens: usage.cumulative_input_tokens.unwrap_or(0), + output_tokens: usage.cumulative_output_tokens.unwrap_or(0), + cost_usd: usage.cumulative_cost_usd.unwrap_or(0.0), + tool_calls: 0, + }; + let content = match serde_json::to_string(&payload) { + Ok(content) => content, + Err(error) => { + tracing::warn!( + target: "pool::metrics", + session_id, + "Agent Studio telemetry serialize failed: {error}" + ); + return; + } + }; + let owner_hex = owner_pk.to_hex(); + let agent_hex = ctx.agent_keys.public_key().to_hex(); + let event = match EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_AGENT_SESSION_TELEMETRY as u16), + content, + ) + .tags([ + Tag::parse(["d", session_id]).expect("d tag"), + Tag::parse(["p", &owner_hex]).expect("p tag"), + Tag::parse(["agent", &agent_hex]).expect("agent tag"), + ]) + .sign_with_keys(&ctx.agent_keys) + { + Ok(event) => event, + Err(error) => { + tracing::warn!( + target: "pool::metrics", + session_id, + "Agent Studio telemetry sign failed: {error}" + ); + return; + } + }; + const TELEMETRY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + match tokio::time::timeout(TELEMETRY_TIMEOUT, ctx.rest_client.submit_event(&event)).await { + Ok(Ok(_)) => {} + Ok(Err(error)) => tracing::warn!( + target: "pool::metrics", + session_id, + "Agent Studio telemetry publish failed: {error}" + ), + Err(_) => tracing::warn!( + target: "pool::metrics", + session_id, + "Agent Studio telemetry publish timed out" + ), + } } const REACTION_SEEN: &str = "👀"; diff --git a/crates/buzz-agent-studio/Cargo.toml b/crates/buzz-agent-studio/Cargo.toml new file mode 100644 index 00000000000..b1a2ac952fd --- /dev/null +++ b/crates/buzz-agent-studio/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "buzz-agent-studio" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Buzz Agent Studio — agent/skill graph ported from claude-code-cli-ui (Buzz Hive P0 skeleton)" + +[dependencies] +buzz-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/buzz-agent-studio/src/events.rs b/crates/buzz-agent-studio/src/events.rs new file mode 100644 index 00000000000..d602d4f278e --- /dev/null +++ b/crates/buzz-agent-studio/src/events.rs @@ -0,0 +1,110 @@ +//! Nostr event kinds and payloads for Buzz Agent Studio (claude-code-cli-ui merge). +//! +//! Kind numbers are defined in `buzz_core::kind` (range 47200–47399). + +pub use buzz_core::kind::{ + is_agent_studio_kind, KIND_AGENT_CONFIG_CREATED, KIND_AGENT_CONFIG_UPDATED, + KIND_AGENT_GRAPH_EDGE, KIND_AGENT_SESSION_TELEMETRY, KIND_AGENT_SKILL_IMPORTED, +}; + +/// Payload for [`KIND_AGENT_CONFIG_CREATED`]. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AgentConfigCreated { + /// Persona / agent slug (`d` tag). + pub agent_id: String, + /// Serialized persona frontmatter or JSON config. + pub config_json: String, +} + +/// Payload for [`KIND_AGENT_CONFIG_UPDATED`]. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AgentConfigUpdated { + /// Persona / agent slug (`d` tag). + pub agent_id: String, + /// Serialized persona frontmatter or JSON config. + pub config_json: String, +} + +/// Payload for [`KIND_AGENT_SESSION_TELEMETRY`]. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AgentSessionTelemetry { + /// Session identifier. + pub session_id: String, + /// Agent / persona slug when known. + pub agent_id: Option, + /// Cumulative input tokens. + pub input_tokens: u64, + /// Cumulative output tokens. + pub output_tokens: u64, + /// Estimated USD cost. + pub cost_usd: f64, + /// Tool invocation count. + pub tool_calls: u32, +} + +/// Payload for [`KIND_AGENT_GRAPH_EDGE`]. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AgentGraphEdge { + /// Source node kind label. + pub source_type: String, + /// Source slug. + pub source_slug: String, + /// Target node kind label. + pub target_type: String, + /// Target slug. + pub target_slug: String, + /// Edge semantics. + pub relationship_type: String, + /// Detection evidence. + pub evidence: String, +} + +/// Payload for [`KIND_AGENT_SKILL_IMPORTED`]. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AgentSkillImported { + /// Skill identifier. + pub skill_id: String, + /// Source repository URL when imported from GitHub. + pub source_repo: Option, + /// Commit SHA at import time. + pub source_commit: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_config_json_roundtrip() { + let event = AgentConfigCreated { + agent_id: "reviewer".into(), + config_json: r#"{"model":"claude-sonnet"}"#.into(), + }; + let json = serde_json::to_string(&event).expect("serialize"); + let back: AgentConfigCreated = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, event); + } + + #[test] + fn skill_import_json_roundtrip() { + let event = AgentSkillImported { + skill_id: "lint-rust".into(), + source_repo: Some("https://github.com/example/skills".into()), + source_commit: Some("abc123".into()), + }; + let json = serde_json::to_string(&event).expect("serialize"); + let back: AgentSkillImported = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, event); + } + + #[test] + fn kind_range_covers_agent_studio() { + assert!(is_agent_studio_kind(KIND_AGENT_CONFIG_CREATED)); + assert!(is_agent_studio_kind(KIND_AGENT_GRAPH_EDGE)); + assert!(is_agent_studio_kind(47399)); + assert!(!is_agent_studio_kind(47199)); + assert!(!is_agent_studio_kind(47400)); + assert!(!is_agent_studio_kind(48001)); + assert!(!is_agent_studio_kind(48100)); + } +} diff --git a/crates/buzz-agent-studio/src/graph.rs b/crates/buzz-agent-studio/src/graph.rs new file mode 100644 index 00000000000..4ec19026b08 --- /dev/null +++ b/crates/buzz-agent-studio/src/graph.rs @@ -0,0 +1,300 @@ +//! Dependency graph extraction (ported from claude-code-cli-ui `relationships.ts`). + +use serde::{Deserialize, Serialize}; + +/// Entity type in the agent/command/skill graph. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GraphNodeType { + /// Claude Code agent definition. + Agent, + /// Slash command. + Command, + /// Skill pack. + Skill, + /// MCP server (read-only node). + Mcp, +} + +/// Edge semantics between graph nodes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RelationshipType { + /// Command body spawns or references an agent. + Spawns, + /// Frontmatter declares a dependency. + AgentFrontmatter, + /// Inverse spawn reference. + SpawnedBy, +} + +/// A directed edge in the agent studio dependency graph. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Relationship { + /// Source node kind. + pub source_type: GraphNodeType, + /// Source slug (agent name, command path, skill id). + pub source_slug: String, + /// Target node kind. + pub target_type: GraphNodeType, + /// Target slug. + pub target_slug: String, + /// How the edge was detected. + pub relationship_type: RelationshipType, + /// Human-readable evidence (frontmatter key, matched snippet). + pub evidence: String, +} + +/// Agent markdown entry scanned from `.claude/agents/*.md`. +#[derive(Clone, Debug)] +pub struct AgentEntry { + /// Agent slug (filename without `.md`). + pub slug: String, + /// Markdown body after frontmatter. + pub body: String, + /// Parsed YAML frontmatter values. + pub skills: Vec, +} + +/// Command markdown entry. +#[derive(Clone, Debug)] +pub struct CommandEntry { + /// Command slug (may include `--` for nested paths). + pub slug: String, + /// Markdown body. + pub body: String, + /// Optional `agent:` frontmatter reference. + pub agent_ref: Option, +} + +/// Skill entry. +#[derive(Clone, Debug)] +pub struct SkillEntry { + /// Skill directory name / slug. + pub slug: String, +} + +/// Build dependency edges from scanned Claude Code config entries. +pub fn extract_relationships( + agents: &[AgentEntry], + commands: &[CommandEntry], + skills: &[SkillEntry], + extra_skill_slugs: &[String], +) -> Vec { + let agent_names: std::collections::HashSet<&str> = + agents.iter().map(|a| a.slug.as_str()).collect(); + let mut skill_slugs: std::collections::HashSet<&str> = + skills.iter().map(|s| s.slug.as_str()).collect(); + for slug in extra_skill_slugs { + skill_slugs.insert(slug.as_str()); + } + + let mut relationships = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + let mut add = |rel: Relationship| { + let key = format!( + "{:?}:{}->{:?}:{}", + rel.source_type, rel.source_slug, rel.target_type, rel.target_slug + ); + if seen.insert(key) { + relationships.push(rel); + } + }; + + for agent in agents { + for skill_slug in &agent.skills { + if skill_slugs.contains(skill_slug.as_str()) { + add(Relationship { + source_type: GraphNodeType::Agent, + source_slug: agent.slug.clone(), + target_type: GraphNodeType::Skill, + target_slug: skill_slug.clone(), + relationship_type: RelationshipType::AgentFrontmatter, + evidence: format!("preloads skill: {skill_slug}"), + }); + } + } + } + + for cmd in commands { + if let Some(agent_ref) = &cmd.agent_ref { + if agent_names.contains(agent_ref.as_str()) { + add(Relationship { + source_type: GraphNodeType::Command, + source_slug: cmd.slug.clone(), + target_type: GraphNodeType::Agent, + target_slug: agent_ref.clone(), + relationship_type: RelationshipType::AgentFrontmatter, + evidence: format!("agent: {agent_ref}"), + }); + } + } + + if cmd.body.to_ascii_lowercase().contains("subagent_type") { + if let Some(idx) = cmd.body.to_ascii_lowercase().find("subagent_type") { + let tail = &cmd.body[idx..]; + if let Some(spawned) = parse_quoted_slug_after_colon(tail) { + if agent_names.contains(spawned.as_str()) { + add(Relationship { + source_type: GraphNodeType::Command, + source_slug: cmd.slug.clone(), + target_type: GraphNodeType::Agent, + target_slug: spawned, + relationship_type: RelationshipType::Spawns, + evidence: "subagent_type reference".into(), + }); + } + } + } + } + + for spawn in find_spawn_patterns(&cmd.body) { + if agent_names.contains(spawn.as_str()) { + add(Relationship { + source_type: GraphNodeType::Command, + source_slug: cmd.slug.clone(), + target_type: GraphNodeType::Agent, + target_slug: spawn, + relationship_type: RelationshipType::Spawns, + evidence: "spawn pattern".into(), + }); + } + } + } + + relationships +} + +/// Serialize graph as `{ nodes, edges }` for HTTP clients. +pub fn graph_json( + agents: &[AgentEntry], + commands: &[CommandEntry], + skills: &[SkillEntry], + extra_skill_slugs: &[String], +) -> serde_json::Value { + let edges = extract_relationships(agents, commands, skills, extra_skill_slugs); + + let mut node_ids = std::collections::BTreeSet::new(); + for agent in agents { + node_ids.insert(format!("agent:{}", agent.slug)); + } + for cmd in commands { + node_ids.insert(format!("command:{}", cmd.slug)); + } + for skill in skills { + node_ids.insert(format!("skill:{}", skill.slug)); + } + for edge in &edges { + node_ids.insert(format!( + "{}:{}", + node_kind_label(&edge.source_type), + edge.source_slug + )); + node_ids.insert(format!( + "{}:{}", + node_kind_label(&edge.target_type), + edge.target_slug + )); + } + + let nodes: Vec = node_ids + .into_iter() + .map(|id| { + let (kind, slug) = id.split_once(':').unwrap_or((&id, "")); + serde_json::json!({ "id": id, "kind": kind, "slug": slug }) + }) + .collect(); + + serde_json::json!({ "nodes": nodes, "edges": edges }) +} + +fn node_kind_label(kind: &GraphNodeType) -> &'static str { + match kind { + GraphNodeType::Agent => "agent", + GraphNodeType::Command => "command", + GraphNodeType::Skill => "skill", + GraphNodeType::Mcp => "mcp", + } +} + +fn parse_quoted_slug_after_colon(text: &str) -> Option { + let after = text.split(':').nth(1)?.trim(); + let slug = after + .trim_start_matches(['"', '\'']) + .split(|c: char| c.is_whitespace() || c == '"' || c == '\'') + .next()? + .trim(); + if slug.is_empty() || !slug.starts_with(|c: char| c.is_ascii_lowercase()) { + return None; + } + Some(slug.to_string()) +} + +fn find_spawn_patterns(body: &str) -> Vec { + let mut found = Vec::new(); + let lower = body.to_ascii_lowercase(); + for prefix in ["spawn ", "spawns ", "spawned "] { + let mut start = 0; + while let Some(idx) = lower[start..].find(prefix) { + let abs = start + idx + prefix.len(); + let tail = body.get(abs..).unwrap_or(""); + let trimmed = tail + .trim_start() + .strip_prefix("the ") + .or_else(|| tail.trim_start().strip_prefix("The ")) + .unwrap_or(tail.trim_start()); + if let Some(slug) = trimmed + .trim_start_matches(['"', '\'']) + .split(|c: char| c.is_whitespace() || c == '"' || c == '\'') + .next() + { + if slug.starts_with(|c: char| c.is_ascii_lowercase()) { + found.push(slug.to_string()); + } + } + start = abs; + } + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_skill_frontmatter_edge() { + let agents = vec![AgentEntry { + slug: "reviewer".into(), + body: String::new(), + skills: vec!["lint".into()], + }]; + let skills = vec![SkillEntry { + slug: "lint".into(), + }]; + let edges = extract_relationships(&agents, &[], &skills, &[]); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].target_slug, "lint"); + } + + #[test] + fn command_agent_frontmatter_edge() { + let agents = vec![AgentEntry { + slug: "worker".into(), + body: String::new(), + skills: vec![], + }]; + let commands = vec![CommandEntry { + slug: "ship".into(), + body: String::new(), + agent_ref: Some("worker".into()), + }]; + let edges = extract_relationships(&agents, &commands, &[], &[]); + assert_eq!(edges.len(), 1); + assert_eq!( + edges[0].relationship_type, + RelationshipType::AgentFrontmatter + ); + } +} diff --git a/crates/buzz-agent-studio/src/graph_events.rs b/crates/buzz-agent-studio/src/graph_events.rs new file mode 100644 index 00000000000..ee53f54761d --- /dev/null +++ b/crates/buzz-agent-studio/src/graph_events.rs @@ -0,0 +1,82 @@ +//! Build graph nodes/edges from Nostr Agent Studio events (kinds 47200+, 47350+). + +use crate::events::{AgentConfigCreated, AgentSkillImported}; +use crate::graph::{ + graph_json, AgentEntry, CommandEntry, GraphNodeType, RelationshipType, SkillEntry, +}; + +/// Parsed graph edge from kind 47350 event content. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct GraphEdgeEvent { + /// Source node kind. + pub source_type: GraphNodeType, + /// Source slug. + pub source_slug: String, + /// Target node kind. + pub target_type: GraphNodeType, + /// Target slug. + pub target_slug: String, + /// Edge semantics. + pub relationship_type: RelationshipType, + /// Detection evidence. + pub evidence: String, +} + +/// Merge scanned filesystem entries with persisted Nostr events. +pub fn graph_from_events( + agents: &[AgentEntry], + commands: &[CommandEntry], + skills: &[SkillEntry], + configs: &[AgentConfigCreated], + imported_skills: &[AgentSkillImported], + edge_events: &[GraphEdgeEvent], +) -> serde_json::Value { + let mut skill_entries: Vec = skills.to_vec(); + for imported in imported_skills { + if !skill_entries.iter().any(|s| s.slug == imported.skill_id) { + skill_entries.push(SkillEntry { + slug: imported.skill_id.clone(), + }); + } + } + + let mut agent_entries: Vec = agents.to_vec(); + for cfg in configs { + if !agent_entries.iter().any(|a| a.slug == cfg.agent_id) { + agent_entries.push(AgentEntry { + slug: cfg.agent_id.clone(), + body: String::new(), + skills: vec![], + }); + } + } + + let mut extra_skills: Vec = Vec::new(); + extra_skills.sort(); + extra_skills.dedup(); + + let mut base = graph_json(&agent_entries, commands, &skill_entries, &extra_skills); + if let Some(edges) = base.get_mut("edges").and_then(|v| v.as_array_mut()) { + for ev in edge_events { + edges.push(serde_json::to_value(ev).unwrap_or_default()); + } + } + base +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn imported_skills_add_nodes() { + let imported = vec![AgentSkillImported { + skill_id: "lint-rust".into(), + source_repo: None, + source_commit: None, + }]; + let graph = graph_from_events(&[], &[], &[], &[], &imported, &[]); + let nodes = graph["nodes"].as_array().expect("nodes"); + assert!(nodes.iter().any(|n| n["slug"] == "lint-rust")); + } +} diff --git a/crates/buzz-agent-studio/src/graph_loader.rs b/crates/buzz-agent-studio/src/graph_loader.rs new file mode 100644 index 00000000000..5311cc2d258 --- /dev/null +++ b/crates/buzz-agent-studio/src/graph_loader.rs @@ -0,0 +1,93 @@ +//! Parse stored Nostr events into Agent Studio graph JSON. + +use buzz_core::kind::{ + KIND_AGENT_CONFIG_CREATED, KIND_AGENT_CONFIG_UPDATED, KIND_AGENT_GRAPH_EDGE, + KIND_AGENT_SKILL_IMPORTED, +}; + +use crate::events::{AgentConfigCreated, AgentGraphEdge, AgentSkillImported}; +use crate::graph_events::{graph_from_events, GraphEdgeEvent}; + +/// Minimal stored event fields needed for graph projection. +#[derive(Clone, Debug)] +pub struct StoredAgentStudioEvent { + /// Nostr event kind. + pub kind: u32, + /// Event content JSON. + pub content: String, +} + +/// Build `{ nodes, edges }` from relay-stored Agent Studio events. +pub fn graph_from_stored_events(events: &[StoredAgentStudioEvent]) -> serde_json::Value { + let mut configs = Vec::new(); + let mut imported_skills = Vec::new(); + let mut edge_events = Vec::new(); + + for event in events { + match event.kind { + KIND_AGENT_CONFIG_CREATED | KIND_AGENT_CONFIG_UPDATED => { + if let Ok(payload) = serde_json::from_str::(&event.content) { + configs.push(payload); + } + } + KIND_AGENT_SKILL_IMPORTED => { + if let Ok(payload) = serde_json::from_str::(&event.content) { + imported_skills.push(payload); + } + } + KIND_AGENT_GRAPH_EDGE => { + if let Ok(raw) = serde_json::from_str::(&event.content) { + edge_events.push(parse_graph_edge(&raw)); + } + } + _ => {} + } + } + + graph_from_events(&[], &[], &[], &configs, &imported_skills, &edge_events) +} + +fn parse_graph_edge(raw: &AgentGraphEdge) -> GraphEdgeEvent { + use crate::graph::{GraphNodeType, RelationshipType}; + + let parse_kind = |label: &str| -> GraphNodeType { + match label { + "command" => GraphNodeType::Command, + "skill" => GraphNodeType::Skill, + "mcp" => GraphNodeType::Mcp, + _ => GraphNodeType::Agent, + } + }; + let parse_rel = |label: &str| -> RelationshipType { + match label { + "spawns" => RelationshipType::Spawns, + "spawned-by" => RelationshipType::SpawnedBy, + _ => RelationshipType::AgentFrontmatter, + } + }; + + GraphEdgeEvent { + source_type: parse_kind(&raw.source_type), + source_slug: raw.source_slug.clone(), + target_type: parse_kind(&raw.target_type), + target_slug: raw.target_slug.clone(), + relationship_type: parse_rel(&raw.relationship_type), + evidence: raw.evidence.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_event_adds_agent_node() { + let content = r#"{"agent_id":"reviewer","config_json":"{}"}"#; + let graph = graph_from_stored_events(&[StoredAgentStudioEvent { + kind: KIND_AGENT_CONFIG_CREATED, + content: content.into(), + }]); + let nodes = graph["nodes"].as_array().expect("nodes"); + assert!(nodes.iter().any(|n| n["slug"] == "reviewer")); + } +} diff --git a/crates/buzz-agent-studio/src/lib.rs b/crates/buzz-agent-studio/src/lib.rs new file mode 100644 index 00000000000..f3500a092e4 --- /dev/null +++ b/crates/buzz-agent-studio/src/lib.rs @@ -0,0 +1,13 @@ +#![deny(unsafe_code)] +#![warn(missing_docs)] +//! `buzz-agent-studio` — Agent Studio backend for Buzz Hive. +//! +//! Port target: [Ngxba/claude-code-cli-ui](https://github.com/Ngxba/claude-code-cli-ui). +//! Kind registry: `buzz_core::kind` range 47200–47399. + +pub mod events; +pub mod graph; +pub mod graph_events; +pub mod graph_loader; +pub mod monitor; +pub mod skill_import; diff --git a/crates/buzz-agent-studio/src/monitor.rs b/crates/buzz-agent-studio/src/monitor.rs new file mode 100644 index 00000000000..e3d6d98cc0e --- /dev/null +++ b/crates/buzz-agent-studio/src/monitor.rs @@ -0,0 +1,84 @@ +//! ACP session telemetry → Nostr kind 47300 (Session Monitor). + +use serde::{Deserialize, Serialize}; + +/// Single telemetry snapshot for an agent session. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SessionTelemetry { + /// Session identifier (ACP session id or run id). + pub session_id: String, + /// Agent / persona slug when known. + pub agent_id: Option, + /// Cumulative input tokens. + pub input_tokens: u64, + /// Cumulative output tokens. + pub output_tokens: u64, + /// Estimated USD cost (community-defined pricing). + pub cost_usd: f64, + /// Tool invocations in this session. + pub tool_calls: u32, + /// Unix timestamp (seconds). + pub recorded_at: i64, +} + +/// In-memory ring buffer for SSE / polling (MVP). +#[derive(Clone, Debug, Default)] +pub struct SessionMonitor { + max_entries: usize, + entries: Vec, +} + +impl SessionMonitor { + /// Create a monitor retaining the last `max_entries` snapshots. + pub fn new(max_entries: usize) -> Self { + Self { + max_entries: max_entries.max(1), + entries: Vec::new(), + } + } + + /// Record a telemetry snapshot. + pub fn record(&mut self, telemetry: SessionTelemetry) { + if self.entries.len() >= self.max_entries { + self.entries.remove(0); + } + self.entries.push(telemetry); + } + + /// Latest snapshots (newest last). + pub fn snapshots(&self) -> &[SessionTelemetry] { + &self.entries + } + + /// Aggregate cost across all retained sessions. + pub fn total_cost_usd(&self) -> f64 { + self.entries.iter().map(|e| e.cost_usd).sum() + } +} + +/// JSON array for HTTP API. +pub fn telemetry_json(entries: &[SessionTelemetry]) -> serde_json::Value { + serde_json::json!({ "sessions": entries }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monitor_retains_max_entries() { + let mut monitor = SessionMonitor::new(2); + for i in 0..3 { + monitor.record(SessionTelemetry { + session_id: format!("s{i}"), + agent_id: None, + input_tokens: 1, + output_tokens: 1, + cost_usd: 0.01, + tool_calls: 0, + recorded_at: i, + }); + } + assert_eq!(monitor.snapshots().len(), 2); + } +} diff --git a/crates/buzz-agent-studio/src/skill_import.rs b/crates/buzz-agent-studio/src/skill_import.rs new file mode 100644 index 00000000000..60e374205d5 --- /dev/null +++ b/crates/buzz-agent-studio/src/skill_import.rs @@ -0,0 +1,96 @@ +//! GitHub skill import (ported from claude-code-cli-ui import workflow). + +use crate::events::AgentSkillImported; + +/// Parsed GitHub repository reference from user input. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GithubRepoRef { + /// `owner/repo` slug. + pub slug: String, + /// Optional branch or tag. + pub r#ref: Option, +} + +/// Planned skill import before event emission. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillImportPlan { + /// Target skill id (directory slug). + pub skill_id: String, + /// Source repository. + pub repo: GithubRepoRef, + /// Optional subdirectory within the repo. + pub path: Option, +} + +/// Errors during skill import planning. +#[derive(Debug, thiserror::Error)] +pub enum SkillImportError { + /// URL or slug could not be parsed. + #[error("invalid GitHub URL: {0}")] + InvalidUrl(String), + /// Skill id failed validation. + #[error("invalid skill id: {0}")] + InvalidSkillId(String), +} + +/// Parse `https://github.com/owner/repo` or `owner/repo` into a repo ref. +pub fn parse_github_repo(input: &str) -> Result { + let trimmed = input.trim().trim_end_matches('/'); + let slug = trimmed + .strip_prefix("https://github.com/") + .or_else(|| trimmed.strip_prefix("http://github.com/")) + .unwrap_or(trimmed); + let parts: Vec<&str> = slug.split('/').filter(|p| !p.is_empty()).collect(); + if parts.len() < 2 { + return Err(SkillImportError::InvalidUrl(input.to_string())); + } + Ok(GithubRepoRef { + slug: format!("{}/{}", parts[0], parts[1]), + r#ref: parts.get(2).map(|s| s.to_string()), + }) +} + +/// Build an import plan from repo URL and skill slug. +pub fn plan_skill_import( + repo_input: &str, + skill_id: &str, + path: Option<&str>, +) -> Result { + if skill_id.is_empty() + || !skill_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err(SkillImportError::InvalidSkillId(skill_id.to_string())); + } + Ok(SkillImportPlan { + skill_id: skill_id.to_string(), + repo: parse_github_repo(repo_input)?, + path: path.map(str::to_string), + }) +} + +/// Convert a successful import plan to a Nostr event payload (kind 47250). +pub fn import_plan_to_event(plan: &SkillImportPlan, commit: Option<&str>) -> AgentSkillImported { + AgentSkillImported { + skill_id: plan.skill_id.clone(), + source_repo: Some(format!("https://github.com/{}", plan.repo.slug)), + source_commit: commit.map(str::to_string), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_github_https() { + let repo = parse_github_repo("https://github.com/block/buzz").expect("parse"); + assert_eq!(repo.slug, "block/buzz"); + } + + #[test] + fn plan_rejects_bad_skill_id() { + assert!(plan_skill_import("block/buzz", "../bad", None).is_err()); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913d..7012cae2490 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -581,7 +581,47 @@ pub const KIND_WORKFLOW_APPROVAL_GRANTED: u32 = 46011; /// A pending workflow approval was denied. pub const KIND_WORKFLOW_APPROVAL_DENIED: u32 = 46012; -// User groups (47000–47999) +// Flow Studio (46200–46399) — Buzz Hive / Sim visual workflow merge. +/// Flow graph saved to the event log. +pub const KIND_FLOW_GRAPH_SAVED: u32 = 46200; +/// A Flow Studio block started execution. +pub const KIND_FLOW_BLOCK_EXECUTED: u32 = 46201; +/// A Flow Studio block failed. +pub const KIND_FLOW_BLOCK_FAILED: u32 = 46202; +/// Knowledge base document ingested. +pub const KIND_FLOW_KB_DOCUMENT_INGESTED: u32 = 46250; +/// Embedding indexed for semantic search. +pub const KIND_FLOW_KB_EMBEDDING_INDEXED: u32 = 46251; +/// Semantic query recorded. +pub const KIND_FLOW_KB_SEMANTIC_QUERY: u32 = 46252; +/// Tables row created. +pub const KIND_FLOW_TABLE_ROW_CREATED: u32 = 46300; +/// Tables row updated. +pub const KIND_FLOW_TABLE_ROW_UPDATED: u32 = 46301; +/// Tables row deleted. +pub const KIND_FLOW_TABLE_ROW_DELETED: u32 = 46302; +/// File uploaded via Flow Studio. +pub const KIND_FLOW_FILE_UPLOADED: u32 = 46350; +/// File version recorded. +pub const KIND_FLOW_FILE_VERSIONED: u32 = 46351; +/// File deleted. +pub const KIND_FLOW_FILE_DELETED: u32 = 46352; + +// Agent Studio (47200–47399) — Buzz Hive / claude-code-cli-ui merge. +// Occupies the reserved user-groups band (47000–47999); does not overlap +// audit (48001) or huddle (48100+) kinds. +/// Agent config created (persona binding). +pub const KIND_AGENT_CONFIG_CREATED: u32 = 47200; +/// Agent config updated. +pub const KIND_AGENT_CONFIG_UPDATED: u32 = 47201; +/// Skill or command imported (e.g. from GitHub). +pub const KIND_AGENT_SKILL_IMPORTED: u32 = 47250; +/// Session telemetry: token usage, cost, tool-call per turn. +pub const KIND_AGENT_SESSION_TELEMETRY: u32 = 47300; +/// Dependency graph edge (agent→command, command→skill). +pub const KIND_AGENT_GRAPH_EDGE: u32 = 47350; + +// User groups (47000–47999) — Agent Studio kinds above use 47200–47399. // System / admin custom range (48000–48999) /// An audit log entry was recorded. @@ -745,6 +785,23 @@ pub const ALL_KINDS: &[u32] = &[ KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_WORKFLOW_APPROVAL_GRANTED, KIND_WORKFLOW_APPROVAL_DENIED, + KIND_FLOW_GRAPH_SAVED, + KIND_FLOW_BLOCK_EXECUTED, + KIND_FLOW_BLOCK_FAILED, + KIND_FLOW_KB_DOCUMENT_INGESTED, + KIND_FLOW_KB_EMBEDDING_INDEXED, + KIND_FLOW_KB_SEMANTIC_QUERY, + KIND_FLOW_TABLE_ROW_CREATED, + KIND_FLOW_TABLE_ROW_UPDATED, + KIND_FLOW_TABLE_ROW_DELETED, + KIND_FLOW_FILE_UPLOADED, + KIND_FLOW_FILE_VERSIONED, + KIND_FLOW_FILE_DELETED, + KIND_AGENT_CONFIG_CREATED, + KIND_AGENT_CONFIG_UPDATED, + KIND_AGENT_SKILL_IMPORTED, + KIND_AGENT_SESSION_TELEMETRY, + KIND_AGENT_GRAPH_EDGE, KIND_AUDIT_ENTRY, KIND_HUDDLE_STARTED, KIND_HUDDLE_PARTICIPANT_JOINED, @@ -790,6 +847,16 @@ pub const fn is_workflow_execution_kind(kind: u32) -> bool { kind >= KIND_WORKFLOW_TRIGGERED && kind <= KIND_WORKFLOW_APPROVAL_DENIED } +/// Returns `true` if `kind` is a Flow Studio event (46200–46399). +pub const fn is_flow_studio_kind(kind: u32) -> bool { + kind >= KIND_FLOW_GRAPH_SAVED && kind <= 46399 +} + +/// Returns `true` if `kind` is an Agent Studio event (47200–47399). +pub const fn is_agent_studio_kind(kind: u32) -> bool { + kind >= KIND_AGENT_CONFIG_CREATED && kind <= 47399 +} + /// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032) /// or the Buzz workspace-profile admin command (9033). pub const fn is_relay_admin_kind(kind: u32) -> bool { @@ -864,6 +931,13 @@ const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 3062 const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 +const _: () = assert!(is_flow_studio_kind(KIND_FLOW_GRAPH_SAVED)); +const _: () = assert!(is_flow_studio_kind(46399)); +const _: () = assert!(!is_flow_studio_kind(KIND_WORKFLOW_APPROVAL_DENIED)); +const _: () = assert!(is_agent_studio_kind(KIND_AGENT_CONFIG_CREATED)); +const _: () = assert!(is_agent_studio_kind(47399)); +const _: () = assert!(!is_agent_studio_kind(KIND_AUDIT_ENTRY)); +const _: () = assert!(!is_agent_studio_kind(KIND_HUDDLE_STARTED)); // Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. const _: () = assert!( diff --git a/crates/buzz-db/src/flow_studio.rs b/crates/buzz-db/src/flow_studio.rs new file mode 100644 index 00000000000..656e30a364b --- /dev/null +++ b/crates/buzz-db/src/flow_studio.rs @@ -0,0 +1,516 @@ +//! Flow Studio read-model tables (Buzz Hive projector target). + +use buzz_core::tenant::CommunityId; +use sqlx::{PgPool, Row}; + +use crate::error::Result; + +/// A knowledge-base search hit (keyword match on chunk content). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FlowKnowledgeSearchHit { + /// Document identifier. + pub document_id: String, + /// Chunk index within the document. + pub chunk_index: i32, + /// Matching chunk text. + pub content: String, +} + +/// A table row in the Flow Studio read-model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FlowTableRowRecord { + /// Row identifier within the table. + pub row_id: String, + /// Row payload as JSON. + pub row_json: serde_json::Value, +} + +/// File metadata in the Flow Studio read-model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FlowFileRecord { + /// File identifier. + pub file_id: String, + /// Original filename. + pub filename: String, + /// Blossom media URL when uploaded. + pub media_url: Option, + /// Monotonic version counter. + pub version: i32, +} + +/// Upsert a knowledge document row from a Flow Studio ingest event. +pub async fn upsert_knowledge_document( + pool: &PgPool, + community_id: CommunityId, + knowledge_base_id: &str, + document_id: &str, + filename: &str, + mime_type: &str, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO flow_knowledge_documents + (community_id, document_id, knowledge_base_id, filename, mime_type) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (community_id, document_id) DO UPDATE SET + knowledge_base_id = EXCLUDED.knowledge_base_id, + filename = EXCLUDED.filename, + mime_type = EXCLUDED.mime_type, + ingested_at = NOW() + "#, + ) + .bind(community_id.as_uuid()) + .bind(document_id) + .bind(knowledge_base_id) + .bind(filename) + .bind(mime_type) + .execute(pool) + .await?; + Ok(()) +} + +/// Upsert a table row from a Flow Studio table event. +pub async fn upsert_table_row( + pool: &PgPool, + community_id: CommunityId, + table_id: &str, + row_id: &str, + row_json: &str, +) -> Result<()> { + let row_value: serde_json::Value = serde_json::from_str(row_json)?; + sqlx::query( + r#" + INSERT INTO flow_table_rows + (community_id, table_id, row_id, row_json, updated_at, deleted_at) + VALUES ($1, $2, $3, $4, NOW(), NULL) + ON CONFLICT (community_id, table_id, row_id) DO UPDATE SET + row_json = EXCLUDED.row_json, + updated_at = NOW(), + deleted_at = NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(table_id) + .bind(row_id) + .bind(row_value) + .execute(pool) + .await?; + Ok(()) +} + +/// Soft-delete a table row from a Flow Studio delete event. +pub async fn delete_table_row( + pool: &PgPool, + community_id: CommunityId, + table_id: &str, + row_id: &str, +) -> Result<()> { + sqlx::query( + r#" + UPDATE flow_table_rows + SET deleted_at = NOW(), updated_at = NOW() + WHERE community_id = $1 AND table_id = $2 AND row_id = $3 + "#, + ) + .bind(community_id.as_uuid()) + .bind(table_id) + .bind(row_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Index a single text chunk for keyword / vector search. +pub async fn upsert_knowledge_embedding( + pool: &PgPool, + community_id: CommunityId, + document_id: &str, + embedding_id: &str, + chunk_index: i32, + content: &str, + embedding: &[f32], +) -> Result<()> { + let vector_literal = format_pgvector(embedding); + sqlx::query( + r#" + INSERT INTO flow_knowledge_embeddings + (community_id, embedding_id, document_id, chunk_index, content, embedding) + VALUES ($1, $2, $3, $4, $5, $6::vector) + ON CONFLICT (community_id, embedding_id) DO UPDATE SET + content = EXCLUDED.content, + chunk_index = EXCLUDED.chunk_index, + embedding = EXCLUDED.embedding, + created_at = NOW() + "#, + ) + .bind(community_id.as_uuid()) + .bind(embedding_id) + .bind(document_id) + .bind(chunk_index) + .bind(content) + .bind(vector_literal) + .execute(pool) + .await?; + Ok(()) +} + +fn format_pgvector(values: &[f32]) -> String { + let inner: Vec = values.iter().map(|v| v.to_string()).collect(); + format!("[{}]", inner.join(",")) +} + +/// Cosine-distance semantic search over indexed chunks (`<=>` operator). +pub async fn search_knowledge_semantic( + pool: &PgPool, + community_id: CommunityId, + knowledge_base_id: &str, + query_embedding: &[f32], + limit: i64, +) -> Result> { + let capped = limit.clamp(1, 50); + let vector_literal = format_pgvector(query_embedding); + let rows = sqlx::query( + r#" + SELECT e.document_id, e.chunk_index, e.content, + (e.embedding <=> $3::vector) AS distance + FROM flow_knowledge_embeddings e + INNER JOIN flow_knowledge_documents d + ON d.community_id = e.community_id AND d.document_id = e.document_id + WHERE e.community_id = $1 + AND d.knowledge_base_id = $2 + ORDER BY distance ASC + LIMIT $4 + "#, + ) + .bind(community_id.as_uuid()) + .bind(knowledge_base_id) + .bind(vector_literal) + .bind(capped) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .filter_map(|row| { + Some(FlowKnowledgeSearchHit { + document_id: row.try_get("document_id").ok()?, + chunk_index: row.try_get("chunk_index").ok()?, + content: row.try_get("content").ok()?, + }) + }) + .collect()) +} + +/// List active rows for a table. +pub async fn list_table_rows( + pool: &PgPool, + community_id: CommunityId, + table_id: &str, + limit: i64, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT row_id, row_json + FROM flow_table_rows + WHERE community_id = $1 AND table_id = $2 AND deleted_at IS NULL + ORDER BY updated_at DESC + LIMIT $3 + "#, + ) + .bind(community_id.as_uuid()) + .bind(table_id) + .bind(limit.clamp(1, 200)) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .filter_map(|row| { + Some(FlowTableRowRecord { + row_id: row.try_get("row_id").ok()?, + row_json: row.try_get("row_json").ok()?, + }) + }) + .collect()) +} + +/// Upsert file metadata from a Flow Studio file event. +pub async fn upsert_flow_file( + pool: &PgPool, + community_id: CommunityId, + file_id: &str, + filename: &str, + media_url: Option<&str>, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO flow_files + (community_id, file_id, filename, media_url, version, deleted_at, updated_at) + VALUES ($1, $2, $3, $4, 1, NULL, NOW()) + ON CONFLICT (community_id, file_id) DO UPDATE SET + filename = EXCLUDED.filename, + media_url = EXCLUDED.media_url, + version = flow_files.version + 1, + deleted_at = NULL, + updated_at = NOW() + "#, + ) + .bind(community_id.as_uuid()) + .bind(file_id) + .bind(filename) + .bind(media_url) + .execute(pool) + .await?; + Ok(()) +} + +/// Soft-delete file metadata. +pub async fn delete_flow_file( + pool: &PgPool, + community_id: CommunityId, + file_id: &str, +) -> Result<()> { + sqlx::query( + r#" + UPDATE flow_files + SET deleted_at = NOW(), updated_at = NOW() + WHERE community_id = $1 AND file_id = $2 + "#, + ) + .bind(community_id.as_uuid()) + .bind(file_id) + .execute(pool) + .await?; + Ok(()) +} + +/// List active files for a community. +pub async fn list_flow_files( + pool: &PgPool, + community_id: CommunityId, + limit: i64, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT file_id, filename, media_url, version + FROM flow_files + WHERE community_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC + LIMIT $2 + "#, + ) + .bind(community_id.as_uuid()) + .bind(limit.clamp(1, 200)) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .filter_map(|row| { + Some(FlowFileRecord { + file_id: row.try_get("file_id").ok()?, + filename: row.try_get("filename").ok()?, + media_url: row.try_get("media_url").ok(), + version: row.try_get("version").ok()?, + }) + }) + .collect()) +} + +/// Load the latest saved canvas graph for a flow id (kind 46200, `d` tag). +pub async fn get_latest_flow_graph( + pool: &PgPool, + community_id: CommunityId, + flow_id: &str, +) -> Result> { + let row = sqlx::query( + r#" + SELECT content + FROM events + WHERE community_id = $1 + AND kind = 46200 + AND channel_id IS NULL + AND deleted_at IS NULL + AND tags @> $2::jsonb + ORDER BY created_at DESC, id ASC + LIMIT 1 + "#, + ) + .bind(community_id.as_uuid()) + .bind(serde_json::json!([["d", flow_id]])) + .fetch_optional(pool) + .await?; + + Ok(row.and_then(|row| row.try_get("content").ok())) +} + +/// Keyword search over indexed knowledge chunks and document filenames. +pub async fn search_knowledge_content( + pool: &PgPool, + community_id: CommunityId, + knowledge_base_id: &str, + query: &str, + limit: i64, +) -> Result> { + let capped = limit.clamp(1, 50); + let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_")); + + let chunk_rows = sqlx::query( + r#" + SELECT e.document_id, e.chunk_index, e.content + FROM flow_knowledge_embeddings e + INNER JOIN flow_knowledge_documents d + ON d.community_id = e.community_id AND d.document_id = e.document_id + WHERE e.community_id = $1 + AND d.knowledge_base_id = $2 + AND e.content ILIKE $3 ESCAPE '\' + ORDER BY e.created_at DESC + LIMIT $4 + "#, + ) + .bind(community_id.as_uuid()) + .bind(knowledge_base_id) + .bind(&pattern) + .bind(capped) + .fetch_all(pool) + .await?; + + let mut hits: Vec = chunk_rows + .into_iter() + .filter_map(|row| { + Some(FlowKnowledgeSearchHit { + document_id: row.try_get("document_id").ok()?, + chunk_index: row.try_get("chunk_index").ok()?, + content: row.try_get("content").ok()?, + }) + }) + .collect(); + + if hits.len() >= capped as usize { + return Ok(hits); + } + + let remaining = capped - hits.len() as i64; + let doc_rows = sqlx::query( + r#" + SELECT document_id, filename + FROM flow_knowledge_documents + WHERE community_id = $1 + AND knowledge_base_id = $2 + AND filename ILIKE $3 ESCAPE '\' + ORDER BY ingested_at DESC + LIMIT $4 + "#, + ) + .bind(community_id.as_uuid()) + .bind(knowledge_base_id) + .bind(&pattern) + .bind(remaining) + .fetch_all(pool) + .await?; + + for row in doc_rows { + let document_id: String = row.try_get("document_id").unwrap_or_default(); + if hits.iter().any(|hit| hit.document_id == document_id) { + continue; + } + let filename: String = row.try_get("filename").unwrap_or_default(); + hits.push(FlowKnowledgeSearchHit { + document_id, + chunk_index: -1, + content: filename, + }); + } + + Ok(hits) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::tenant::CommunityId; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("flow-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(&host) + .execute(pool) + .await + .expect("insert community"); + CommunityId::from_uuid(id) + } + + /// Community A must not see Flow Studio read-model rows from community B. + #[tokio::test] + #[ignore = "requires Postgres with migration 0032 applied"] + async fn flow_studio_read_model_is_confined_to_community() { + let pool = setup_pool().await; + let community_a = make_community(&pool).await; + let community_b = make_community(&pool).await; + + upsert_table_row( + &pool, + community_a, + "customers", + "row-a", + r#"{"name":"A-only"}"#, + ) + .await + .expect("insert row A"); + upsert_table_row( + &pool, + community_b, + "customers", + "row-b", + r#"{"name":"B-only"}"#, + ) + .await + .expect("insert row B"); + + let rows_a = list_table_rows(&pool, community_a, "customers", 10) + .await + .expect("list A"); + let rows_b = list_table_rows(&pool, community_b, "customers", 10) + .await + .expect("list B"); + + assert_eq!(rows_a.len(), 1); + assert_eq!(rows_b.len(), 1); + assert_eq!(rows_a[0].row_id, "row-a"); + assert_eq!(rows_b[0].row_id, "row-b"); + + upsert_flow_file(&pool, community_a, "file-a", "a.txt", None) + .await + .expect("file A"); + upsert_flow_file(&pool, community_b, "file-b", "b.txt", None) + .await + .expect("file B"); + + let files_a = list_flow_files(&pool, community_a, 10) + .await + .expect("files A"); + let files_b = list_flow_files(&pool, community_b, 10) + .await + .expect("files B"); + + assert!(files_a.iter().any(|f| f.file_id == "file-a")); + assert!(!files_a.iter().any(|f| f.file_id == "file-b")); + assert!(files_b.iter().any(|f| f.file_id == "file-b")); + assert!(!files_b.iter().any(|f| f.file_id == "file-a")); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 330525d310d..0a74cbfe119 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -27,6 +27,8 @@ pub mod error; pub mod event; /// Home feed queries. pub mod feed; +/// Flow Studio read-model persistence (Buzz Hive). +pub mod flow_studio; /// Git repository name registry (NIP-34 kind:30617). pub mod git_repo; /// Embedded database migrations. @@ -4196,6 +4198,160 @@ impl Db { .await } + /// Upsert a Flow Studio knowledge document read-model row. + #[datastore_span(name = "upsert_flow_knowledge_document", system = "postgresql")] + pub async fn upsert_flow_knowledge_document( + &self, + community_id: CommunityId, + knowledge_base_id: &str, + document_id: &str, + filename: &str, + mime_type: &str, + ) -> Result<()> { + flow_studio::upsert_knowledge_document( + &self.pool, + community_id, + knowledge_base_id, + document_id, + filename, + mime_type, + ) + .await + } + + /// Upsert a Flow Studio table row read-model row. + #[datastore_span(name = "upsert_flow_table_row", system = "postgresql")] + pub async fn upsert_flow_table_row( + &self, + community_id: CommunityId, + table_id: &str, + row_id: &str, + row_json: &str, + ) -> Result<()> { + flow_studio::upsert_table_row(&self.pool, community_id, table_id, row_id, row_json).await + } + + /// Soft-delete a Flow Studio table row read-model row. + #[datastore_span(name = "delete_flow_table_row", system = "postgresql")] + pub async fn delete_flow_table_row( + &self, + community_id: CommunityId, + table_id: &str, + row_id: &str, + ) -> Result<()> { + flow_studio::delete_table_row(&self.pool, community_id, table_id, row_id).await + } + + /// Index a knowledge-base text chunk with an embedding vector. + #[datastore_span(name = "upsert_flow_knowledge_embedding", system = "postgresql")] + pub async fn upsert_flow_knowledge_embedding( + &self, + community_id: CommunityId, + document_id: &str, + embedding_id: &str, + chunk_index: i32, + content: &str, + embedding: &[f32], + ) -> Result<()> { + flow_studio::upsert_knowledge_embedding( + &self.pool, + community_id, + document_id, + embedding_id, + chunk_index, + content, + embedding, + ) + .await + } + + /// Semantic (cosine) search over Flow Studio knowledge chunks. + #[datastore_span(name = "search_flow_knowledge_semantic", system = "postgresql")] + pub async fn search_flow_knowledge_semantic( + &self, + community_id: CommunityId, + knowledge_base_id: &str, + query_embedding: &[f32], + limit: i64, + ) -> Result> { + flow_studio::search_knowledge_semantic( + &self.pool, + community_id, + knowledge_base_id, + query_embedding, + limit, + ) + .await + } + + /// List active table rows for a Flow Studio table. + #[datastore_span(name = "list_flow_table_rows", system = "postgresql")] + pub async fn list_flow_table_rows( + &self, + community_id: CommunityId, + table_id: &str, + limit: i64, + ) -> Result> { + flow_studio::list_table_rows(&self.pool, community_id, table_id, limit).await + } + + /// Upsert Flow Studio file metadata. + #[datastore_span(name = "upsert_flow_file", system = "postgresql")] + pub async fn upsert_flow_file( + &self, + community_id: CommunityId, + file_id: &str, + filename: &str, + media_url: Option<&str>, + ) -> Result<()> { + flow_studio::upsert_flow_file(&self.pool, community_id, file_id, filename, media_url).await + } + + /// Soft-delete Flow Studio file metadata. + #[datastore_span(name = "delete_flow_file", system = "postgresql")] + pub async fn delete_flow_file(&self, community_id: CommunityId, file_id: &str) -> Result<()> { + flow_studio::delete_flow_file(&self.pool, community_id, file_id).await + } + + /// List active Flow Studio files. + #[datastore_span(name = "list_flow_files", system = "postgresql")] + pub async fn list_flow_files( + &self, + community_id: CommunityId, + limit: i64, + ) -> Result> { + flow_studio::list_flow_files(&self.pool, community_id, limit).await + } + + /// Keyword search over Flow Studio knowledge chunks. + #[datastore_span(name = "search_flow_knowledge", system = "postgresql")] + pub async fn search_flow_knowledge( + &self, + community_id: CommunityId, + knowledge_base_id: &str, + query: &str, + limit: i64, + ) -> Result> { + flow_studio::search_knowledge_content( + &self.pool, + community_id, + knowledge_base_id, + query, + limit, + ) + .await + } + + /// Load the latest saved Flow Studio canvas graph content for a flow id. + #[datastore_span(name = "get_latest_flow_graph", system = "postgresql")] + pub async fn get_latest_flow_graph( + &self, + community_id: CommunityId, + flow_id: &str, + ) -> Result> { + flow_studio::get_latest_flow_graph(&self.pool, community_id, flow_id).await + } + /// Ensures monthly partitions exist for the next N months. #[datastore_span(name = "ensure_future_partitions", system = "postgresql")] pub async fn ensure_future_partitions(&self, months_ahead: u32) -> Result<()> { diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index be87faa1ac2..9d8cd937e01 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -625,7 +625,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 31); + assert_eq!(migrations.len(), 32); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1059,6 +1059,22 @@ mod tests { assert!(include_str!("../../../schema/schema.sql").contains("error_code TEXT")); } + #[test] + fn buzz_hive_studio_migration_adds_pgvector_read_model() { + let mut migrations: Vec<_> = MIGRATOR.iter().collect(); + migrations.sort_by_key(|migration| migration.version); + + assert_eq!(migrations[31].version, 32); + let sql = migrations[31].sql.as_str(); + assert!(sql.contains("CREATE EXTENSION IF NOT EXISTS vector")); + assert!(sql.contains("flow_knowledge_documents")); + assert!(sql.contains("flow_table_rows")); + assert!(sql.contains("flow_files")); + assert!(include_str!("../../../schema/schema.sql").contains("flow_knowledge_documents")); + assert!(include_str!("../../../schema/schema.sql") + .contains("CREATE EXTENSION IF NOT EXISTS vector")); + } + #[test] fn migration_lint_detects_tables_missing_community_id_by_default() { let sql = r#" diff --git a/crates/buzz-flow/Cargo.toml b/crates/buzz-flow/Cargo.toml new file mode 100644 index 00000000000..1b5ae65c494 --- /dev/null +++ b/crates/buzz-flow/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "buzz-flow" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Buzz Flow Studio — visual workflow blocks ported from Sim (Buzz Hive P0 skeleton)" + +[dependencies] +buzz-core = { workspace = true } +buzz-workflow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +uuid = { workspace = true } diff --git a/crates/buzz-flow/src/blocks/mod.rs b/crates/buzz-flow/src/blocks/mod.rs new file mode 100644 index 00000000000..a1a6cedea46 --- /dev/null +++ b/crates/buzz-flow/src/blocks/mod.rs @@ -0,0 +1,85 @@ +//! Flow Studio block registry (MVP — ported conceptually from Sim `blocks/registry.ts`). + +use serde::{Deserialize, Serialize}; + +/// Block category for palette grouping. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BlockCategory { + /// LLM / agent invocation. + Agent, + /// Branching logic. + Condition, + /// Outbound HTTP call. + Http, + /// Inline code execution. + Code, + /// Human approval gate (uses buzz-workflow WF-08 when enabled). + HumanApproval, +} + +/// Metadata for a draggable canvas block. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockDefinition { + /// Registry key (snake_case). + pub block_type: String, + /// Display name in the palette. + pub name: String, + /// Short description. + pub description: String, + /// Palette section. + pub category: BlockCategory, +} + +/// MVP block catalog — extended as Sim blocks are ported. +pub fn block_catalog() -> Vec { + vec![ + BlockDefinition { + block_type: "agent".into(), + name: "Agent".into(), + description: "Run a Buzz persona / managed agent".into(), + category: BlockCategory::Agent, + }, + BlockDefinition { + block_type: "condition".into(), + name: "Condition".into(), + description: "Branch on an evalexpr condition".into(), + category: BlockCategory::Condition, + }, + BlockDefinition { + block_type: "http".into(), + name: "HTTP Request".into(), + description: "Call an external webhook or REST endpoint".into(), + category: BlockCategory::Http, + }, + BlockDefinition { + block_type: "code".into(), + name: "Code".into(), + description: "Execute a sandboxed code step".into(), + category: BlockCategory::Code, + }, + BlockDefinition { + block_type: "human_approval".into(), + name: "Human Approval".into(), + description: "Pause until a channel member approves".into(), + category: BlockCategory::HumanApproval, + }, + ] +} + +/// JSON catalog for `GET /flow-studio/blocks`. +pub fn blocks_json() -> serde_json::Value { + serde_json::json!({ "blocks": block_catalog() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_has_mvp_blocks() { + let blocks = block_catalog(); + assert!(blocks.iter().any(|b| b.block_type == "agent")); + assert!(blocks.iter().any(|b| b.block_type == "human_approval")); + } +} diff --git a/crates/buzz-flow/src/event_payloads.rs b/crates/buzz-flow/src/event_payloads.rs new file mode 100644 index 00000000000..c46cfe1f8dd --- /dev/null +++ b/crates/buzz-flow/src/event_payloads.rs @@ -0,0 +1,134 @@ +//! Additional Flow Studio event payloads (kinds 46200–46399). + +use serde::{Deserialize, Serialize}; + +/// Payload for [`KIND_FLOW_BLOCK_EXECUTED`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowBlockExecuted { + /// Flow identifier (`d` tag). + pub flow_id: String, + /// Block instance id on the canvas. + pub block_id: String, + /// Block registry type (e.g. `http`). + pub block_type: String, + /// Redacted output summary. + pub output_json: String, +} + +/// Payload for [`KIND_FLOW_BLOCK_FAILED`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowBlockFailed { + /// Flow identifier (`d` tag). + pub flow_id: String, + /// Block instance id on the canvas. + pub block_id: String, + /// Block registry type (e.g. `http`). + pub block_type: String, + /// Human-readable failure reason. + pub error: String, +} + +/// Payload for [`KIND_FLOW_KB_DOCUMENT_INGESTED`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowKbDocumentIngested { + /// Knowledge base identifier. + pub knowledge_base_id: String, + /// Document identifier (`d` tag). + pub document_id: String, + /// Original filename. + pub filename: String, + /// MIME type of the ingested document. + pub mime_type: String, + /// Optional plain-text body indexed as a single embedding chunk (MVP keyword search). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +/// Payload for [`KIND_FLOW_KB_EMBEDDING_INDEXED`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowKbEmbeddingIndexed { + /// Parent document identifier. + pub document_id: String, + /// Unique embedding row identifier. + pub embedding_id: String, + /// Chunk index within the document. + pub chunk_index: i32, +} + +/// Payload for [`KIND_FLOW_FILE_UPLOADED`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowFileUploaded { + /// File identifier (`d` tag). + pub file_id: String, + /// Original filename. + pub filename: String, + /// Blossom media URL when bytes were uploaded. + pub media_url: Option, +} + +/// Payload for [`KIND_FLOW_FILE_DELETED`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowFileDeleted { + /// File identifier (`d` tag). + pub file_id: String, +} + +/// Payload for [`KIND_FLOW_KB_SEMANTIC_QUERY`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowKbSemanticQuery { + /// Knowledge base to search. + pub knowledge_base_id: String, + /// Natural-language query string. + pub query: String, + /// Maximum hits to return. + pub top_k: u32, +} + +/// Payload for [`KIND_FLOW_TABLE_ROW_CREATED`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowTableRowCreated { + /// Table identifier. + pub table_id: String, + /// Row identifier within the table. + pub row_id: String, + /// Serialized row JSON. + pub row_json: String, +} + +/// Payload for [`KIND_FLOW_TABLE_ROW_UPDATED`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowTableRowUpdated { + /// Table identifier. + pub table_id: String, + /// Row identifier within the table. + pub row_id: String, + /// Serialized row JSON. + pub row_json: String, +} + +/// Payload for [`KIND_FLOW_TABLE_ROW_DELETED`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowTableRowDeleted { + /// Table identifier. + pub table_id: String, + /// Row identifier within the table. + pub row_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flow_block_executed_roundtrip() { + let payload = FlowBlockExecuted { + flow_id: "onboarding".into(), + block_id: "step-1".into(), + block_type: "http".into(), + output_json: r#"{"status":200}"#.into(), + }; + let json = serde_json::to_string(&payload).expect("serialize"); + let back: FlowBlockExecuted = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, payload); + } +} diff --git a/crates/buzz-flow/src/events.rs b/crates/buzz-flow/src/events.rs new file mode 100644 index 00000000000..137b2b71174 --- /dev/null +++ b/crates/buzz-flow/src/events.rs @@ -0,0 +1,44 @@ +//! Nostr event kinds and payloads for Buzz Flow Studio (Sim merge). +//! +//! Kind numbers are defined in `buzz_core::kind` (range 46200–46399). + +pub use buzz_core::kind::{ + is_flow_studio_kind, KIND_FLOW_BLOCK_EXECUTED, KIND_FLOW_BLOCK_FAILED, KIND_FLOW_FILE_DELETED, + KIND_FLOW_FILE_UPLOADED, KIND_FLOW_FILE_VERSIONED, KIND_FLOW_GRAPH_SAVED, + KIND_FLOW_KB_DOCUMENT_INGESTED, KIND_FLOW_KB_EMBEDDING_INDEXED, KIND_FLOW_KB_SEMANTIC_QUERY, + KIND_FLOW_TABLE_ROW_CREATED, KIND_FLOW_TABLE_ROW_DELETED, KIND_FLOW_TABLE_ROW_UPDATED, +}; + +/// Payload for [`KIND_FLOW_GRAPH_SAVED`]. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FlowGraphSaved { + /// Stable flow identifier (`d` tag). + pub flow_id: String, + /// Serialized canvas graph (nodes + edges). + pub graph_json: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flow_graph_saved_json_roundtrip() { + let event = FlowGraphSaved { + flow_id: "onboarding".into(), + graph_json: r#"{"nodes":[],"edges":[]}"#.into(), + }; + let json = serde_json::to_string(&event).expect("serialize"); + let back: FlowGraphSaved = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, event); + } + + #[test] + fn kind_range_covers_flow_studio() { + assert!(is_flow_studio_kind(KIND_FLOW_GRAPH_SAVED)); + assert!(is_flow_studio_kind(KIND_FLOW_FILE_DELETED)); + assert!(is_flow_studio_kind(46399)); + assert!(!is_flow_studio_kind(46199)); + assert!(!is_flow_studio_kind(46400)); + } +} diff --git a/crates/buzz-flow/src/files.rs b/crates/buzz-flow/src/files.rs new file mode 100644 index 00000000000..76991bb033c --- /dev/null +++ b/crates/buzz-flow/src/files.rs @@ -0,0 +1,74 @@ +//! Flow Studio file metadata (content stored via Buzz media / Blossom). + +use serde::{Deserialize, Serialize}; + +/// File metadata projected from kind 46350–46399 events. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowFileRecord { + /// File identifier. + pub file_id: String, + /// Original filename. + pub filename: String, + /// Blossom media URL when uploaded. + pub media_url: Option, + /// Monotonic version counter. + pub version: u32, + /// Whether the file was soft-deleted. + pub deleted: bool, +} + +/// Bump version on upload; mark deleted on delete event. +pub fn apply_file_event( + files: &mut Vec, + file_id: &str, + filename: &str, + media_url: Option, + deleted: bool, +) { + if deleted { + if let Some(existing) = files.iter_mut().find(|f| f.file_id == file_id) { + existing.deleted = true; + } + return; + } + + if let Some(existing) = files.iter_mut().find(|f| f.file_id == file_id) { + existing.filename = filename.to_string(); + existing.media_url = media_url; + existing.version += 1; + existing.deleted = false; + } else { + files.push(FlowFileRecord { + file_id: file_id.to_string(), + filename: filename.to_string(), + media_url, + version: 1, + deleted: false, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upload_then_version() { + let mut files = Vec::new(); + apply_file_event( + &mut files, + "f1", + "doc.pdf", + Some("https://relay/media/x".into()), + false, + ); + apply_file_event( + &mut files, + "f1", + "doc-v2.pdf", + Some("https://relay/media/y".into()), + false, + ); + assert_eq!(files[0].version, 2); + } +} diff --git a/crates/buzz-flow/src/knowledge/embed.rs b/crates/buzz-flow/src/knowledge/embed.rs new file mode 100644 index 00000000000..7d1b3d36321 --- /dev/null +++ b/crates/buzz-flow/src/knowledge/embed.rs @@ -0,0 +1,80 @@ +//! Deterministic text → vector embedding (MVP semantic search without an external model). + +/// OpenAI-compatible embedding width used by migration 0032. +pub const EMBEDDING_DIM: usize = 1536; + +/// Hash token activations into a fixed-size vector, then L2-normalize. +/// +/// Not a production embedding model — sufficient for dev/MVP cosine ranking over +/// ingested Flow Studio documents until a real model pipeline is wired. +pub fn text_to_embedding(text: &str) -> Vec { + let mut values = vec![0.0f32; EMBEDDING_DIM]; + for token in text.split_whitespace() { + let normalized = token.to_ascii_lowercase(); + if normalized.is_empty() { + continue; + } + let hash = fnv1a64(normalized.as_bytes()); + let idx = (hash as usize) % EMBEDDING_DIM; + values[idx] += 1.0; + let idx2 = ((hash >> 32) as usize) % EMBEDDING_DIM; + values[idx2] += 0.5; + } + l2_normalize(&mut values); + values +} + +/// Format a vector for Postgres `pgvector` query parameters. +pub fn embedding_to_pgvector(values: &[f32]) -> String { + let inner: Vec = values.iter().map(|v| v.to_string()).collect(); + format!("[{}]", inner.join(",")) +} + +fn fnv1a64(bytes: &[u8]) -> u64 { + const OFFSET: u64 = 0xcbf29ce484222325; + const PRIME: u64 = 0x00000100000001B3; + let mut hash = OFFSET; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(PRIME); + } + hash +} + +fn l2_normalize(values: &mut [f32]) { + let sum_sq: f32 = values.iter().map(|v| v * v).sum(); + if sum_sq <= f32::EPSILON { + return; + } + let norm = sum_sq.sqrt(); + for value in values { + *value /= norm; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedding_is_normalized() { + let embedding = text_to_embedding("Buzz Hive knowledge base"); + assert_eq!(embedding.len(), EMBEDDING_DIM); + let sum_sq: f32 = embedding.iter().map(|v| v * v).sum(); + assert!((sum_sq - 1.0).abs() < 0.001); + } + + #[test] + fn similar_text_has_higher_dot_than_unrelated() { + let a = text_to_embedding("rust workflow automation"); + let b = text_to_embedding("rust workflow engine"); + let c = text_to_embedding("chocolate cake recipe"); + let ab = dot(&a, &b); + let ac = dot(&a, &c); + assert!(ab > ac); + } + + fn dot(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() + } +} diff --git a/crates/buzz-flow/src/knowledge/mod.rs b/crates/buzz-flow/src/knowledge/mod.rs new file mode 100644 index 00000000000..2f3f28b86b3 --- /dev/null +++ b/crates/buzz-flow/src/knowledge/mod.rs @@ -0,0 +1,68 @@ +//! Knowledge base helpers (P3 — pgvector read-model). + +pub mod embed; + +use serde::{Deserialize, Serialize}; + +/// In-memory document record before projector persistence. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KnowledgeDocument { + /// Knowledge base identifier. + pub knowledge_base_id: String, + /// Document identifier. + pub document_id: String, + /// Original filename. + pub filename: String, + /// MIME type of the document body. + pub mime_type: String, + /// Plain-text document content. + pub content: String, +} + +/// Semantic search hit (MVP — full vector search wired in P3 projector). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SemanticSearchHit { + /// Matching document identifier. + pub document_id: String, + /// Chunk index within the document. + pub chunk_index: i32, + /// Matching chunk text. + pub content: String, + /// Relevance score (higher is better). + pub score: f32, +} + +/// Naive keyword fallback when pgvector is unavailable. +pub fn keyword_search(documents: &[KnowledgeDocument], query: &str) -> Vec { + let needle = query.to_ascii_lowercase(); + let mut hits = Vec::new(); + for doc in documents { + if doc.content.to_ascii_lowercase().contains(&needle) { + hits.push(SemanticSearchHit { + document_id: doc.document_id.clone(), + chunk_index: 0, + content: doc.content.chars().take(200).collect(), + score: 1.0, + }); + } + } + hits +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keyword_search_finds_match() { + let docs = vec![KnowledgeDocument { + knowledge_base_id: "kb1".into(), + document_id: "d1".into(), + filename: "readme.txt".into(), + mime_type: "text/plain".into(), + content: "Buzz Hive knowledge base".into(), + }]; + let hits = keyword_search(&docs, "hive"); + assert_eq!(hits.len(), 1); + } +} diff --git a/crates/buzz-flow/src/lib.rs b/crates/buzz-flow/src/lib.rs new file mode 100644 index 00000000000..466d4b46143 --- /dev/null +++ b/crates/buzz-flow/src/lib.rs @@ -0,0 +1,16 @@ +#![deny(unsafe_code)] +#![warn(missing_docs)] +//! `buzz-flow` — Flow Studio backend for Buzz Hive. +//! +//! Port target: [simstudioai/sim](https://github.com/simstudioai/sim). +//! See `docs/BUZZ_HIVE_MERGE_SPEC.md` for kind range 46200–46399. + +pub mod blocks; +pub mod event_payloads; +pub mod events; +pub mod files; +pub mod knowledge; +pub mod projector; +pub mod tables; +pub mod tools; +pub mod workflow_bridge; diff --git a/crates/buzz-flow/src/projector.rs b/crates/buzz-flow/src/projector.rs new file mode 100644 index 00000000000..db78d1d2078 --- /dev/null +++ b/crates/buzz-flow/src/projector.rs @@ -0,0 +1,132 @@ +//! Event → Postgres projector (P3 read-model). + +use buzz_core::tenant::CommunityId; + +use crate::event_payloads::{ + FlowFileDeleted, FlowFileUploaded, FlowKbDocumentIngested, FlowTableRowCreated, + FlowTableRowDeleted, +}; +use crate::events::{ + KIND_FLOW_FILE_DELETED, KIND_FLOW_FILE_UPLOADED, KIND_FLOW_KB_DOCUMENT_INGESTED, + KIND_FLOW_TABLE_ROW_CREATED, KIND_FLOW_TABLE_ROW_DELETED, KIND_FLOW_TABLE_ROW_UPDATED, +}; + +/// Projector instruction derived from a stored Nostr event. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProjectorAction { + /// Upsert a knowledge document row. + UpsertKnowledgeDocument { + /// Community that owns the document row. + community_id: CommunityId, + /// Parsed ingest event payload. + payload: FlowKbDocumentIngested, + }, + /// Upsert a table row. + UpsertTableRow { + /// Community that owns the table row. + community_id: CommunityId, + /// Table identifier. + table_id: String, + /// Row identifier within the table. + row_id: String, + /// Serialized row JSON. + row_json: String, + }, + /// Soft-delete a table row. + DeleteTableRow { + /// Community that owns the table row. + community_id: CommunityId, + /// Table identifier. + table_id: String, + /// Row identifier within the table. + row_id: String, + }, + /// Upsert file metadata (content lives in Buzz media). + UpsertFile { + /// Community that owns the file row. + community_id: CommunityId, + /// File identifier. + file_id: String, + /// Original filename. + filename: String, + /// Blossom media URL when uploaded. + media_url: Option, + }, + /// Soft-delete file metadata. + DeleteFile { + /// Community that owns the file row. + community_id: CommunityId, + /// File identifier. + file_id: String, + }, +} + +/// Map a Flow Studio kind + JSON content to a projector action (MVP). +pub fn project_event( + community_id: CommunityId, + kind: u32, + content: &str, +) -> Result, serde_json::Error> { + match kind { + KIND_FLOW_KB_DOCUMENT_INGESTED => { + let payload: FlowKbDocumentIngested = serde_json::from_str(content)?; + Ok(Some(ProjectorAction::UpsertKnowledgeDocument { + community_id, + payload, + })) + } + KIND_FLOW_TABLE_ROW_CREATED | KIND_FLOW_TABLE_ROW_UPDATED => { + let payload: FlowTableRowCreated = serde_json::from_str(content)?; + Ok(Some(ProjectorAction::UpsertTableRow { + community_id, + table_id: payload.table_id, + row_id: payload.row_id, + row_json: payload.row_json, + })) + } + KIND_FLOW_TABLE_ROW_DELETED => { + let payload: FlowTableRowDeleted = serde_json::from_str(content)?; + Ok(Some(ProjectorAction::DeleteTableRow { + community_id, + table_id: payload.table_id, + row_id: payload.row_id, + })) + } + KIND_FLOW_FILE_UPLOADED => { + let payload: FlowFileUploaded = serde_json::from_str(content)?; + Ok(Some(ProjectorAction::UpsertFile { + community_id, + file_id: payload.file_id, + filename: payload.filename, + media_url: payload.media_url, + })) + } + KIND_FLOW_FILE_DELETED => { + let payload: FlowFileDeleted = serde_json::from_str(content)?; + Ok(Some(ProjectorAction::DeleteFile { + community_id, + file_id: payload.file_id, + })) + } + _ => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + #[test] + fn projects_kb_ingest() { + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + let content = r#"{"knowledge_base_id":"kb","document_id":"d1","filename":"a.txt","mime_type":"text/plain"}"#; + let action = project_event(community_id, KIND_FLOW_KB_DOCUMENT_INGESTED, content) + .expect("project") + .expect("some"); + assert!(matches!( + action, + ProjectorAction::UpsertKnowledgeDocument { .. } + )); + } +} diff --git a/crates/buzz-flow/src/tables.rs b/crates/buzz-flow/src/tables.rs new file mode 100644 index 00000000000..bab8e9f3be6 --- /dev/null +++ b/crates/buzz-flow/src/tables.rs @@ -0,0 +1,51 @@ +//! Flow Studio table row helpers (event-backed CRUD). + +use serde::{Deserialize, Serialize}; + +/// A table row in the Flow Studio read-model. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TableRow { + /// Table identifier. + pub table_id: String, + /// Row identifier within the table. + pub row_id: String, + /// Row payload as JSON. + pub row_json: serde_json::Value, +} + +/// Apply a create/update/delete event to an in-memory row set (projector logic). +pub fn apply_row_event( + rows: &mut Vec, + table_id: &str, + row_id: &str, + row_json: Option, + deleted: bool, +) { + rows.retain(|r| !(r.table_id == table_id && r.row_id == row_id)); + if !deleted { + if let Some(json) = row_json { + rows.push(TableRow { + table_id: table_id.to_string(), + row_id: row_id.to_string(), + row_json: json, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn apply_delete_removes_row() { + let mut rows = vec![TableRow { + table_id: "t".into(), + row_id: "1".into(), + row_json: json!({"x": 1}), + }]; + apply_row_event(&mut rows, "t", "1", None, true); + assert!(rows.is_empty()); + } +} diff --git a/crates/buzz-flow/src/tools/mod.rs b/crates/buzz-flow/src/tools/mod.rs new file mode 100644 index 00000000000..779deba646a --- /dev/null +++ b/crates/buzz-flow/src/tools/mod.rs @@ -0,0 +1,50 @@ +//! Tool registry for Flow Studio blocks (MVP stub — extended from Sim `tools/`). + +use serde::{Deserialize, Serialize}; + +/// Tool attached to an agent block. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowToolDefinition { + /// Registry key. + pub tool_type: String, + /// Display name. + pub name: String, + /// Short description. + pub description: String, +} + +/// MVP tool catalog. +pub fn tool_catalog() -> Vec { + vec![ + FlowToolDefinition { + tool_type: "shell".into(), + name: "Shell".into(), + description: "Run a sandboxed shell command".into(), + }, + FlowToolDefinition { + tool_type: "web_fetch".into(), + name: "Web fetch".into(), + description: "Fetch a URL and return body text".into(), + }, + FlowToolDefinition { + tool_type: "buzz_messages".into(), + name: "Buzz messages".into(), + description: "Read or post channel messages via relay".into(), + }, + ] +} + +/// JSON catalog for API consumers. +pub fn tools_json() -> serde_json::Value { + serde_json::json!({ "tools": tool_catalog() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_has_tools() { + assert!(!tool_catalog().is_empty()); + } +} diff --git a/crates/buzz-flow/src/workflow_bridge.rs b/crates/buzz-flow/src/workflow_bridge.rs new file mode 100644 index 00000000000..460846c57e2 --- /dev/null +++ b/crates/buzz-flow/src/workflow_bridge.rs @@ -0,0 +1,149 @@ +//! Map Flow Studio canvas blocks to `buzz-workflow` YAML actions. + +use buzz_workflow::schema::{ActionDef, Step}; + +use crate::blocks::BlockCategory; + +/// Canvas block instance before YAML conversion. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CanvasBlock { + /// Unique instance id on the canvas. + pub id: String, + /// Registry block type (`agent`, `http`, …). + pub block_type: String, + /// Block-specific config JSON. + pub config_json: serde_json::Value, +} + +/// Convert a canvas block to a workflow step (MVP mapping). +pub fn block_to_step(block: &CanvasBlock) -> Result { + let action = match block.block_type.as_str() { + "agent" => { + let persona = block + .config_json + .get("persona") + .and_then(|v| v.as_str()) + .unwrap_or("default"); + ActionDef::SendMessage { + text: format!("Run agent persona: {persona}"), + channel: None, + } + } + "condition" => { + let expr = block + .config_json + .get("expression") + .and_then(|v| v.as_str()) + .unwrap_or("true"); + return Ok(Step { + id: block.id.clone(), + name: None, + if_expr: Some(expr.to_string()), + timeout_secs: None, + block_type: Some(block.block_type.clone()), + action: ActionDef::SendMessage { + text: "{{trigger.text}}".into(), + channel: None, + }, + }); + } + "http" => { + let url = block + .config_json + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| BridgeError::MissingField("url".into()))?; + ActionDef::CallWebhook { + url: url.to_string(), + method: block + .config_json + .get("method") + .and_then(|v| v.as_str()) + .map(str::to_string), + headers: None, + body: block + .config_json + .get("body") + .and_then(|v| v.as_str()) + .map(str::to_string), + } + } + "human_approval" => { + let from = block + .config_json + .get("from") + .and_then(|v| v.as_str()) + .unwrap_or("@anyone"); + let message = block + .config_json + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Approve this step?"); + ActionDef::RequestApproval { + from: from.to_string(), + message: message.to_string(), + timeout: block + .config_json + .get("timeout") + .and_then(|v| v.as_str()) + .map(str::to_string), + } + } + "code" => ActionDef::SendMessage { + text: "Code block (sandbox not wired)".into(), + channel: None, + }, + other => { + return Err(BridgeError::UnknownBlockType(other.to_string())); + } + }; + + Ok(Step { + id: block.id.clone(), + name: None, + if_expr: None, + timeout_secs: None, + block_type: Some(block.block_type.clone()), + action, + }) +} + +/// Category label for palette grouping in YAML export metadata. +pub fn category_for_block_type(block_type: &str) -> Option { + match block_type { + "agent" => Some(BlockCategory::Agent), + "condition" => Some(BlockCategory::Condition), + "http" => Some(BlockCategory::Http), + "code" => Some(BlockCategory::Code), + "human_approval" => Some(BlockCategory::HumanApproval), + _ => None, + } +} + +/// Bridge errors when converting canvas → workflow. +#[derive(Debug, thiserror::Error)] +pub enum BridgeError { + /// Unknown block registry key. + #[error("unknown block type: {0}")] + UnknownBlockType(String), + /// Required config field missing. + #[error("missing required field: {0}")] + MissingField(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn human_approval_maps_to_request_approval() { + let block = CanvasBlock { + id: "gate".into(), + block_type: "human_approval".into(), + config_json: json!({"from": "@mgr", "message": "OK?"}), + }; + let step = block_to_step(&block).expect("map"); + assert!(matches!(step.action, ActionDef::RequestApproval { .. })); + } +} diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index deb2e7e16a5..2b9c30028cd 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -63,6 +63,8 @@ reqwest = { workspace = true } base64 = "0.22" buzz-sdk = { workspace = true } buzz-workflow = { workspace = true, features = ["reqwest"] } +buzz-flow = { workspace = true } +buzz-agent-studio = { workspace = true } buzz-media = { workspace = true } s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] } tempfile = "3" diff --git a/crates/buzz-relay/src/api/agent_studio.rs b/crates/buzz-relay/src/api/agent_studio.rs new file mode 100644 index 00000000000..f77a8852dac --- /dev/null +++ b/crates/buzz-relay/src/api/agent_studio.rs @@ -0,0 +1,246 @@ +//! Agent Studio HTTP surface (Buzz Hive). + +use std::sync::{Arc, OnceLock}; + +use axum::{extract::State, http::HeaderMap, response::Json, Json as JsonBody}; +use buzz_agent_studio::graph_loader::{graph_from_stored_events, StoredAgentStudioEvent}; +use buzz_agent_studio::{ + monitor::{telemetry_json, SessionMonitor, SessionTelemetry}, + skill_import::{import_plan_to_event, plan_skill_import}, +}; +use buzz_core::kind::{ + KIND_AGENT_CONFIG_CREATED, KIND_AGENT_CONFIG_UPDATED, KIND_AGENT_GRAPH_EDGE, + KIND_AGENT_SKILL_IMPORTED, KIND_FLOW_BLOCK_EXECUTED, +}; +use buzz_db::event::EventQuery; +use buzz_flow::event_payloads::FlowBlockExecuted; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::Mutex; + +use crate::state::AppState; + +fn session_monitor() -> &'static Mutex { + static MONITOR: OnceLock> = OnceLock::new(); + MONITOR.get_or_init(|| Mutex::new(SessionMonitor::new(100))) +} + +async fn community_from_host( + state: &AppState, + headers: &HeaderMap, +) -> Option { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + crate::tenant::bind_community(&state.db, raw_host) + .await + .ok() + .map(|tenant| tenant.community()) +} + +/// `GET /agent-studio/graph` — dependency graph from stored Agent Studio events. +pub async fn graph(State(state): State>, headers: HeaderMap) -> Json { + let Some(community_id) = community_from_host(state.as_ref(), &headers).await else { + return Json(buzz_agent_studio::graph::graph_json(&[], &[], &[], &[])); + }; + + let kinds = vec![ + KIND_AGENT_CONFIG_CREATED as i32, + KIND_AGENT_CONFIG_UPDATED as i32, + KIND_AGENT_SKILL_IMPORTED as i32, + KIND_AGENT_GRAPH_EDGE as i32, + ]; + let mut query = EventQuery::for_community(community_id); + query.kinds = Some(kinds); + query.limit = Some(500); + query.global_only = true; + + let stored = match state + .db + .query_events_routed("agent_studio_graph", &query) + .await + { + Ok(rows) => rows, + Err(error) => { + tracing::warn!("agent-studio graph query failed: {error}"); + return Json(buzz_agent_studio::graph::graph_json(&[], &[], &[], &[])); + } + }; + + let events: Vec = stored + .into_iter() + .map(|row| StoredAgentStudioEvent { + kind: buzz_core::kind::event_kind_u32(&row.event), + content: row.event.content.to_string(), + }) + .collect(); + + Json(graph_from_stored_events(&events)) +} + +/// `GET /agent-studio/sessions` — recent session telemetry snapshots. +pub async fn sessions(State(_state): State>) -> Json { + let guard = session_monitor().lock().await; + Json(telemetry_json(guard.snapshots())) +} + +/// `GET /agent-studio/costs` — unified ACP session + Flow block cost rollup. +pub async fn costs(State(state): State>, headers: HeaderMap) -> Json { + let guard = session_monitor().lock().await; + let sessions = guard.snapshots().to_vec(); + let acp_session_cost_usd = guard.total_cost_usd(); + let acp_tokens: u64 = sessions + .iter() + .map(|session| session.input_tokens + session.output_tokens) + .sum(); + + let mut flow_block_cost_usd = 0.0f64; + if let Some(community_id) = community_from_host(state.as_ref(), &headers).await { + let mut query = EventQuery::for_community(community_id); + query.kinds = Some(vec![KIND_FLOW_BLOCK_EXECUTED as i32]); + query.limit = Some(200); + query.global_only = true; + if let Ok(stored) = state + .db + .query_events_routed("agent_studio_costs", &query) + .await + { + for row in stored { + let Ok(payload) = + serde_json::from_str::(&row.event.content.to_string()) + else { + continue; + }; + let Ok(output) = serde_json::from_str::(&payload.output_json) else { + continue; + }; + flow_block_cost_usd += output + .get("cost_usd") + .and_then(|value| value.as_f64()) + .unwrap_or(0.0); + } + } + } + + Json(serde_json::json!({ + "total_cost_usd": acp_session_cost_usd + flow_block_cost_usd, + "acp_session_cost_usd": acp_session_cost_usd, + "flow_block_cost_usd": flow_block_cost_usd, + "total_tokens": acp_tokens, + "session_count": sessions.len(), + "sessions": sessions, + })) +} + +/// Request body for skill import planning. +#[derive(Debug, Deserialize)] +pub struct ImportSkillRequest { + /// GitHub repo URL or `owner/repo`. + pub repo: String, + /// Target skill slug. + pub skill_id: String, + /// Optional path within the repo. + pub path: Option, +} + +/// Response for skill import planning (event payload to publish client-side). +#[derive(Debug, Serialize)] +pub struct ImportSkillResponse { + /// Whether the import plan was accepted. + pub accepted: bool, + /// Serialized Nostr event content for kind 47250. + pub event_payload: Value, + /// Human-readable status message. + pub message: String, +} + +/// `POST /agent-studio/skills/import` — plan a skill import (returns event payload). +pub async fn import_skill( + State(_state): State>, + JsonBody(body): JsonBody, +) -> Json { + match plan_skill_import(&body.repo, &body.skill_id, body.path.as_deref()) { + Ok(plan) => { + let payload = import_plan_to_event(&plan, None); + Json(ImportSkillResponse { + accepted: true, + event_payload: serde_json::to_value(&payload).unwrap_or(Value::Null), + message: format!( + "Import planned for skill '{}' from {}", + plan.skill_id, plan.repo.slug + ), + }) + } + Err(e) => Json(ImportSkillResponse { + accepted: false, + event_payload: Value::Null, + message: e.to_string(), + }), + } +} + +/// Record telemetry from ACP harness (internal hook — MVP). +pub async fn record_session_telemetry(telemetry: SessionTelemetry) { + let mut guard = session_monitor().lock().await; + guard.record(telemetry); +} + +/// Request body for direct session telemetry injection (testing / HTTP bridge). +#[derive(Debug, Deserialize)] +pub struct PostTelemetryRequest { + /// ACP session identifier. + pub session_id: String, + /// Optional managed-agent identifier. + pub agent_id: Option, + /// Input tokens consumed this turn. + pub input_tokens: u64, + /// Output tokens produced this turn. + pub output_tokens: u64, + /// Estimated USD cost for this turn. + pub cost_usd: f64, + /// Tool invocations this turn. + #[serde(default)] + pub tool_calls: u32, +} + +/// `POST /agent-studio/telemetry` — record a session telemetry snapshot. +pub async fn post_telemetry( + State(_state): State>, + JsonBody(body): JsonBody, +) -> Json { + let recorded_at = chrono::Utc::now().timestamp(); + record_session_telemetry(SessionTelemetry { + session_id: body.session_id, + agent_id: body.agent_id, + input_tokens: body.input_tokens, + output_tokens: body.output_tokens, + cost_usd: body.cost_usd, + tool_calls: body.tool_calls, + recorded_at, + }) + .await; + Json(serde_json::json!({ "accepted": true })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn sessions_endpoint_returns_json() { + record_session_telemetry(SessionTelemetry { + session_id: "t1".into(), + agent_id: None, + input_tokens: 1, + output_tokens: 1, + cost_usd: 0.0, + tool_calls: 0, + recorded_at: 0, + }) + .await; + let guard = session_monitor().lock().await; + let json = telemetry_json(guard.snapshots()); + assert!(json.get("sessions").and_then(|v| v.as_array()).is_some()); + } +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 0856c85cf36..455209341f0 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1924,6 +1924,7 @@ pub async fn workflow_webhook( // Spawn workflow execution asynchronously. let engine = Arc::clone(&state.workflow_engine); let db = state.db.clone(); + let state = Arc::clone(&state); let def_value = workflow.definition.clone(); let trigger_ctx_clone = trigger_ctx.clone(); tokio::spawn(async move { @@ -1961,9 +1962,12 @@ pub async fn workflow_webhook( None, ) .await; + let trace = crate::flow_telemetry::trace_from_execution(&result, None); engine .finalize_run(community_id, run_id, result, None) .await; + crate::flow_telemetry::publish_flow_block_telemetry(&state, community_id, &def, &trace) + .await; }); Ok(( diff --git a/crates/buzz-relay/src/api/flow_studio.rs b/crates/buzz-relay/src/api/flow_studio.rs new file mode 100644 index 00000000000..91cb54658ee --- /dev/null +++ b/crates/buzz-relay/src/api/flow_studio.rs @@ -0,0 +1,325 @@ +//! Flow Studio HTTP surface (Buzz Hive). + +use std::sync::Arc; + +use axum::{ + extract::{Query, State}, + response::Json, + Json as JsonBody, +}; +use buzz_flow::{ + blocks::blocks_json, + events::FlowGraphSaved, + tools::tools_json, + workflow_bridge::{block_to_step, CanvasBlock}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::state::AppState; + +async fn community_from_host( + state: &AppState, + headers: &axum::http::HeaderMap, +) -> Option { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + crate::tenant::bind_community(&state.db, raw_host) + .await + .ok() + .map(|tenant| tenant.community()) +} + +/// `GET /flow-studio/blocks` — draggable block palette catalog. +pub async fn blocks(State(_state): State>) -> Json { + Json(blocks_json()) +} + +/// `GET /flow-studio/tools` — tool registry for agent blocks. +pub async fn tools(State(_state): State>) -> Json { + Json(tools_json()) +} + +/// Canvas graph input for YAML conversion. +#[derive(Debug, Deserialize)] +pub struct CanvasToYamlRequest { + /// Canvas blocks in execution order. + pub blocks: Vec, + /// Flow Studio canvas id (`d` tag) embedded in exported workflow metadata. + #[serde(default)] + pub flow_id: Option, +} + +/// YAML conversion response. +#[derive(Debug, Serialize)] +pub struct CanvasToYamlResponse { + /// Serialized workflow YAML on success. + pub yaml: String, + /// Error message when conversion fails. + pub error: Option, +} + +/// Save graph request body. +#[derive(Debug, Deserialize)] +pub struct SaveGraphRequest { + /// Flow identifier (`d` tag). + pub flow_id: String, + /// Serialized canvas graph JSON. + pub graph_json: String, +} + +/// Save graph response — event payload for client publish (kind 46200). +#[derive(Debug, Serialize)] +pub struct SaveGraphResponse { + /// Whether the payload was accepted for publish. + pub accepted: bool, + /// Event content to sign and submit as kind 46200. + pub event_payload: Value, + /// Human-readable status message. + pub message: String, +} + +/// Query params for loading a saved canvas graph. +#[derive(Debug, Deserialize)] +pub struct GetGraphQuery { + /// Flow identifier (`d` tag). + pub flow_id: String, +} + +/// Response for a saved canvas graph lookup. +#[derive(Debug, Serialize)] +pub struct GetGraphResponse { + /// Flow identifier echoed from the query. + pub flow_id: String, + /// Serialized canvas graph when found. + pub graph_json: Option, + /// Whether a saved graph exists for this flow id. + pub found: bool, +} + +/// Knowledge search query params. +#[derive(Debug, Deserialize)] +pub struct KnowledgeSearchQuery { + /// Knowledge base to search within. + pub knowledge_base_id: String, + /// Query string (keyword or semantic depending on `mode`). + pub q: String, + /// Maximum hits to return. + #[serde(default = "default_search_limit")] + pub limit: i64, + /// `keyword` (default) or `semantic` (pgvector cosine distance). + #[serde(default = "default_search_mode")] + pub mode: String, +} + +fn default_search_mode() -> String { + "semantic".into() +} + +fn default_search_limit() -> i64 { + 10 +} + +/// `GET /flow-studio/graph` — latest saved canvas for a flow id (kind 46200). +pub async fn get_graph( + State(state): State>, + headers: axum::http::HeaderMap, + Query(query): Query, +) -> Json { + let Some(community_id) = community_from_host(state.as_ref(), &headers).await else { + return Json(GetGraphResponse { + flow_id: query.flow_id, + graph_json: None, + found: false, + }); + }; + + let content = match state + .db + .get_latest_flow_graph(community_id, &query.flow_id) + .await + { + Ok(content) => content, + Err(error) => { + tracing::warn!("flow-studio graph lookup failed: {error}"); + None + } + }; + + let graph_json = content + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()) + .map(|payload| payload.graph_json); + + Json(GetGraphResponse { + flow_id: query.flow_id, + graph_json: graph_json.clone(), + found: graph_json.is_some(), + }) +} + +/// `GET /flow-studio/knowledge/search` — keyword search over indexed chunks. +pub async fn knowledge_search( + State(state): State>, + headers: axum::http::HeaderMap, + Query(query): Query, +) -> Json { + let Some(community_id) = community_from_host(state.as_ref(), &headers).await else { + return Json(serde_json::json!({ "hits": [] })); + }; + + let hits = if query.mode == "keyword" { + state + .db + .search_flow_knowledge( + community_id, + &query.knowledge_base_id, + &query.q, + query.limit, + ) + .await + .unwrap_or_default() + } else { + let embedding = buzz_flow::knowledge::embed::text_to_embedding(&query.q); + state + .db + .search_flow_knowledge_semantic( + community_id, + &query.knowledge_base_id, + &embedding, + query.limit, + ) + .await + .unwrap_or_default() + }; + + Json(serde_json::json!({ + "mode": query.mode, + "hits": hits.into_iter().map(|hit| serde_json::json!({ + "document_id": hit.document_id, + "chunk_index": hit.chunk_index, + "content": hit.content, + })).collect::>() + })) +} + +/// Query params for listing table rows. +#[derive(Debug, Deserialize)] +pub struct ListTableRowsQuery { + /// Maximum rows to return. + #[serde(default = "default_list_limit")] + pub limit: i64, +} + +fn default_list_limit() -> i64 { + 50 +} + +/// `GET /flow-studio/tables/{table_id}/rows` — list projected table rows. +pub async fn list_table_rows( + State(state): State>, + headers: axum::http::HeaderMap, + axum::extract::Path(table_id): axum::extract::Path, + Query(query): Query, +) -> Json { + let Some(community_id) = community_from_host(state.as_ref(), &headers).await else { + return Json(serde_json::json!({ "rows": [] })); + }; + + let rows = state + .db + .list_flow_table_rows(community_id, &table_id, query.limit) + .await + .unwrap_or_default(); + + Json(serde_json::json!({ + "table_id": table_id, + "rows": rows.into_iter().map(|row| serde_json::json!({ + "row_id": row.row_id, + "row_json": row.row_json, + })).collect::>() + })) +} + +/// `GET /flow-studio/files` — list projected file metadata. +pub async fn list_files( + State(state): State>, + headers: axum::http::HeaderMap, + Query(query): Query, +) -> Json { + let Some(community_id) = community_from_host(state.as_ref(), &headers).await else { + return Json(serde_json::json!({ "files": [] })); + }; + + let files = state + .db + .list_flow_files(community_id, query.limit) + .await + .unwrap_or_default(); + + Json(serde_json::json!({ + "files": files.into_iter().map(|file| serde_json::json!({ + "file_id": file.file_id, + "filename": file.filename, + "media_url": file.media_url, + "version": file.version, + })).collect::>() + })) +} + +/// `POST /flow-studio/graph/save` — build FlowGraphSaved event payload. +pub async fn save_graph( + State(_state): State>, + JsonBody(body): JsonBody, +) -> Json { + let payload = buzz_flow::events::FlowGraphSaved { + flow_id: body.flow_id.clone(), + graph_json: body.graph_json, + }; + Json(SaveGraphResponse { + accepted: true, + event_payload: serde_json::to_value(&payload).unwrap_or(Value::Null), + message: format!( + "Publish kind 46200 with d-tag '{}' to persist this canvas", + body.flow_id + ), + }) +} + +/// `POST /flow-studio/yaml/from-canvas` — convert canvas blocks to workflow YAML steps. +pub async fn yaml_from_canvas( + State(_state): State>, + JsonBody(body): JsonBody, +) -> Json { + let mut steps = Vec::new(); + for block in &body.blocks { + match block_to_step(block) { + Ok(step) => steps.push(step), + Err(e) => { + return Json(CanvasToYamlResponse { + yaml: String::new(), + error: Some(e.to_string()), + }); + } + } + } + + let def = buzz_workflow::schema::WorkflowDef { + name: "flow-studio-export".into(), + description: None, + trigger: buzz_workflow::schema::TriggerDef::Webhook, + steps, + enabled: true, + flow_id: body.flow_id, + }; + + match serde_yaml::to_string(&def) { + Ok(yaml) => Json(CanvasToYamlResponse { yaml, error: None }), + Err(e) => Json(CanvasToYamlResponse { + yaml: String::new(), + error: Some(e.to_string()), + }), + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 2a942bc8039..8bbdf79d4da 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -1,8 +1,10 @@ //! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge. pub mod admin; +pub mod agent_studio; pub mod bridge; pub mod events; +pub mod flow_studio; pub mod git; pub mod invites; pub mod media; diff --git a/crates/buzz-relay/src/flow_telemetry.rs b/crates/buzz-relay/src/flow_telemetry.rs new file mode 100644 index 00000000000..e25e0a087a9 --- /dev/null +++ b/crates/buzz-relay/src/flow_telemetry.rs @@ -0,0 +1,157 @@ +//! Publish Flow Studio block execution telemetry (kind 46201) after workflow runs. + +use std::sync::Arc; + +use buzz_core::kind::KIND_FLOW_BLOCK_EXECUTED; +use buzz_core::tenant::{CommunityId, TenantContext}; +use buzz_flow::event_payloads::FlowBlockExecuted; +use buzz_workflow::executor::ExecutionResult; +use buzz_workflow::schema::WorkflowDef; +use buzz_workflow::{PartialProgress, WorkflowError}; +use nostr::{EventBuilder, Kind, Tag}; +use serde_json::Value; + +use crate::handlers::event::dispatch_persistent_event; +use crate::state::AppState; + +fn block_type_for_step(def: &WorkflowDef, step_id: &str) -> String { + def.steps + .iter() + .find(|step| step.id == step_id) + .and_then(|step| step.block_type.clone()) + .unwrap_or_else(|| "unknown".into()) +} + +fn output_with_cost(output: &Value, cost_usd: f64) -> String { + let mut merged = match output { + Value::Object(map) => map.clone(), + other => { + let mut map = serde_json::Map::new(); + map.insert("result".into(), other.clone()); + map + } + }; + merged.insert("cost_usd".into(), Value::from(cost_usd)); + Value::Object(merged).to_string() +} + +fn estimate_cost_usd(block_type: &str) -> f64 { + match block_type { + "agent" => 0.001, + "http" => 0.0001, + "code" => 0.0005, + "human_approval" | "condition" => 0.0, + _ => 0.0, + } +} + +/// Merge an executor result trace with any pre-approval trace entries. +pub fn trace_from_execution( + result: &Result, + existing_trace: Option>, +) -> Vec { + let mut trace = existing_trace.unwrap_or_default(); + match result { + Ok(exec) => trace.extend(exec.trace.clone()), + Err((_, progress)) => trace.extend(progress.trace.clone()), + } + trace +} + +/// Emit kind 46201 events for completed steps when the workflow carries Flow Studio metadata. +pub async fn publish_flow_block_telemetry( + state: &Arc, + community_id: CommunityId, + def: &WorkflowDef, + trace: &[Value], +) { + let Some(flow_id) = def.flow_id.as_deref().filter(|id| !id.is_empty()) else { + return; + }; + + let host = match state.db.lookup_community_host(community_id).await { + Ok(Some(host)) => host, + Ok(None) => { + tracing::warn!( + community_id = %community_id, + "flow telemetry skipped: community host not mapped" + ); + return; + } + Err(error) => { + tracing::warn!("flow telemetry host lookup failed: {error}"); + return; + } + }; + let tenant = TenantContext::resolved(community_id, host); + let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + + for entry in trace { + let Some(status) = entry.get("status").and_then(Value::as_str) else { + continue; + }; + if status != "completed" { + continue; + } + let Some(step_id) = entry.get("step_id").and_then(Value::as_str) else { + continue; + }; + let output = entry.get("output").cloned().unwrap_or(Value::Null); + let block_type = block_type_for_step(def, step_id); + let cost_usd = output + .get("cost_usd") + .and_then(Value::as_f64) + .unwrap_or_else(|| estimate_cost_usd(&block_type)); + let payload = FlowBlockExecuted { + flow_id: flow_id.to_string(), + block_id: step_id.to_string(), + block_type: block_type.clone(), + output_json: output_with_cost(&output, cost_usd), + }; + let content = match serde_json::to_string(&payload) { + Ok(content) => content, + Err(error) => { + tracing::warn!("flow telemetry serialize failed: {error}"); + continue; + } + }; + + let tags = match Tag::parse(["d", flow_id]) { + Ok(tag) => vec![tag], + Err(error) => { + tracing::warn!("flow telemetry d-tag failed: {error}"); + continue; + } + }; + + let event = match EventBuilder::new(Kind::from(KIND_FLOW_BLOCK_EXECUTED as u16), &content) + .tags(tags) + .sign_with_keys(&state.relay_keypair) + { + Ok(event) => event, + Err(error) => { + tracing::warn!("flow telemetry signing failed: {error}"); + continue; + } + }; + + let insert_result = state.db.insert_event(community_id, &event, None).await; + match insert_result { + Ok((stored_event, was_inserted)) if was_inserted => { + let _ = dispatch_persistent_event( + &tenant, + state, + &stored_event, + KIND_FLOW_BLOCK_EXECUTED, + &relay_pubkey_hex, + None, + ) + .await; + } + Ok(_) => {} + Err(error) => { + tracing::warn!(flow_id, step_id, "flow telemetry insert failed: {error}"); + } + } + } +} diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 29abe9f27d4..f9b5d9592db 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -950,6 +950,7 @@ async fn handle_workflow_trigger( // 5. Spawn workflow execution let engine = Arc::clone(&state.workflow_engine); let db = state.db.clone(); + let state = Arc::clone(state); let def_value = workflow.definition.clone(); let trigger_ctx_clone = trigger_ctx.clone(); tokio::spawn(async move { @@ -987,9 +988,12 @@ async fn handle_workflow_trigger( None, ) .await; + let trace = crate::flow_telemetry::trace_from_execution(&result, None); engine .finalize_run(community_id, run_id, result, None) .await; + crate::flow_telemetry::publish_flow_block_telemetry(&state, community_id, &def, &trace) + .await; }); // 6. Return response @@ -1128,11 +1132,18 @@ async fn handle_approval_grant( let workflow_id = approval.workflow_id; let resume_index = approval.step_index as usize + 1; let engine = Arc::clone(&state.workflow_engine); - let db = state.db.clone(); + let state = Arc::clone(state); tokio::spawn(async move { - resume_workflow_after_approval(engine, db, community_id, run_id, workflow_id, resume_index) - .await; + resume_workflow_after_approval( + engine, + state, + community_id, + run_id, + workflow_id, + resume_index, + ) + .await; }); // 7. Return response @@ -1292,12 +1303,13 @@ async fn handle_approval_deny( /// Resume a suspended workflow run after an approval gate has been granted. async fn resume_workflow_after_approval( engine: Arc, - db: buzz_db::Db, + state: Arc, community_id: CommunityId, run_id: Uuid, workflow_id: Uuid, resume_index: usize, ) { + let db = state.db.clone(); let run = match db.get_workflow_run(community_id, run_id).await { Ok(r) => r, Err(e) => { @@ -1381,7 +1393,9 @@ async fn resume_workflow_after_approval( Some(initial_outputs), ) .await; + let trace = crate::flow_telemetry::trace_from_execution(&result, existing_trace.clone()); engine .finalize_run(community_id, run_id, result, existing_trace) .await; + crate::flow_telemetry::publish_flow_block_telemetry(&state, community_id, &def, &trace).await; } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ccba40f3282..5458488f839 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -557,6 +557,33 @@ async fn dispatch_persistent_event_inner( }); } + if buzz_core::kind::is_flow_studio_kind(kind_u32) { + let community_id = tenant.community(); + let content = stored_event.event.content.to_string(); + let db = state.db.clone(); + tokio::spawn(async move { + match buzz_flow::projector::project_event(community_id, kind_u32, &content) { + Ok(Some(action)) => { + if let Err(error) = apply_flow_projector_action(&db, action).await { + tracing::warn!("Flow Studio projector apply failed: {error}"); + } + } + Ok(None) => {} + Err(error) => { + tracing::warn!("Flow Studio projector parse failed: {error}"); + } + } + }); + } + + if kind_u32 == buzz_core::kind::KIND_AGENT_SESSION_TELEMETRY { + let content = stored_event.event.content.to_string(); + let recorded_at = stored_event.event.created_at.as_secs() as i64; + tokio::spawn(async move { + record_agent_session_telemetry_from_content(&content, recorded_at).await; + }); + } + matches.len() } @@ -600,6 +627,100 @@ async fn enqueue_event_created_audit( } } +async fn apply_flow_projector_action( + db: &buzz_db::Db, + action: buzz_flow::projector::ProjectorAction, +) -> buzz_db::error::Result<()> { + use buzz_flow::projector::ProjectorAction; + + match action { + ProjectorAction::UpsertKnowledgeDocument { + community_id, + payload, + } => apply_kb_document_projector(db, community_id, &payload).await, + ProjectorAction::UpsertTableRow { + community_id, + table_id, + row_id, + row_json, + } => { + db.upsert_flow_table_row(community_id, &table_id, &row_id, &row_json) + .await + } + ProjectorAction::DeleteTableRow { + community_id, + table_id, + row_id, + } => { + db.delete_flow_table_row(community_id, &table_id, &row_id) + .await + } + ProjectorAction::UpsertFile { + community_id, + file_id, + filename, + media_url, + } => { + db.upsert_flow_file(community_id, &file_id, &filename, media_url.as_deref()) + .await + } + ProjectorAction::DeleteFile { + community_id, + file_id, + } => db.delete_flow_file(community_id, &file_id).await, + } +} + +async fn apply_kb_document_projector( + db: &buzz_db::Db, + community_id: buzz_core::tenant::CommunityId, + payload: &buzz_flow::event_payloads::FlowKbDocumentIngested, +) -> buzz_db::error::Result<()> { + db.upsert_flow_knowledge_document( + community_id, + &payload.knowledge_base_id, + &payload.document_id, + &payload.filename, + &payload.mime_type, + ) + .await?; + if let Some(content) = payload.content.as_deref().filter(|text| !text.is_empty()) { + let embedding_id = format!("{}:0", payload.document_id); + let embedding = buzz_flow::knowledge::embed::text_to_embedding(content); + db.upsert_flow_knowledge_embedding( + community_id, + &payload.document_id, + &embedding_id, + 0, + content, + &embedding, + ) + .await?; + } + Ok(()) +} + +async fn record_agent_session_telemetry_from_content(content: &str, recorded_at: i64) { + let Ok(payload) = + serde_json::from_str::(content) + else { + tracing::warn!("Agent Studio telemetry parse failed"); + return; + }; + crate::api::agent_studio::record_session_telemetry( + buzz_agent_studio::monitor::SessionTelemetry { + session_id: payload.session_id, + agent_id: payload.agent_id, + input_tokens: payload.input_tokens, + output_tokens: payload.output_tokens, + cost_usd: payload.cost_usd, + tool_calls: payload.tool_calls, + recorded_at, + }, + ) + .await; +} + /// Handle an EVENT message from a WebSocket connection. /// /// Extracts auth from the WS connection, dispatches ephemeral events locally, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5ba9650e91e..a7bdf4c634a 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -450,6 +450,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_WORKFLOW_DEF | KIND_WORKFLOW_TRIGGER => Ok(Scope::MessagesWrite), KIND_APPROVAL_GRANT | KIND_APPROVAL_DENY => Ok(Scope::MessagesWrite), + k if buzz_core::kind::is_flow_studio_kind(k) => Ok(Scope::UsersWrite), + k if buzz_core::kind::is_agent_studio_kind(k) => Ok(Scope::UsersWrite), _ => Err("restricted: unknown event kind"), } } @@ -527,6 +529,9 @@ pub(crate) async fn derive_reaction_channel( /// limitation affecting all global-only kinds and should be addressed in the /// filter layer as a follow-up. pub(crate) fn is_global_only_kind(kind: u32) -> bool { + if buzz_core::kind::is_flow_studio_kind(kind) || buzz_core::kind::is_agent_studio_kind(kind) { + return true; + } matches!( kind, KIND_PROFILE diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314adad92e0..9dadaefc184 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -19,6 +19,8 @@ pub mod conformance; pub mod connection; /// Relay error types. pub mod error; +/// Flow Studio block telemetry after workflow execution. +pub mod flow_telemetry; /// WebSocket message handlers for NIP-01 client commands. pub mod handlers; /// Stateless HMAC-signed relay invite tokens (mint/verify). diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 1dce66e91e4..cf8dfddd67b 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -80,6 +80,37 @@ pub fn build_router(state: Arc) -> Router { "/workflows/{workflow_id}/runs/{run_id}/approvals", get(api::workflows::run_approvals), ) + .route("/agent-studio/graph", get(api::agent_studio::graph)) + .route("/agent-studio/sessions", get(api::agent_studio::sessions)) + .route("/agent-studio/costs", get(api::agent_studio::costs)) + .route( + "/agent-studio/skills/import", + post(api::agent_studio::import_skill), + ) + .route( + "/agent-studio/telemetry", + post(api::agent_studio::post_telemetry), + ) + .route("/flow-studio/blocks", get(api::flow_studio::blocks)) + .route("/flow-studio/tools", get(api::flow_studio::tools)) + .route("/flow-studio/graph", get(api::flow_studio::get_graph)) + .route( + "/flow-studio/knowledge/search", + get(api::flow_studio::knowledge_search), + ) + .route( + "/flow-studio/tables/{table_id}/rows", + get(api::flow_studio::list_table_rows), + ) + .route("/flow-studio/files", get(api::flow_studio::list_files)) + .route( + "/flow-studio/yaml/from-canvas", + post(api::flow_studio::yaml_from_canvas), + ) + .route( + "/flow-studio/graph/save", + post(api::flow_studio::save_graph), + ) .route( "/operator/communities", get(api::operator::list_owned_communities).post(api::operator::provision_community), diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index dffa4927168..73950aed348 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -460,6 +460,12 @@ pub enum StepResult { Suspended { /// Token used to resume or reject this approval gate. approval_token: String, + /// Workflow step id that requested approval. + step_id: String, + /// Who may approve (user mention or role spec). + approver_spec: String, + /// When this approval request expires. + expires_at: chrono::DateTime, }, /// Step was skipped due to `if:` condition being false. Skipped, @@ -678,12 +684,15 @@ pub async fn dispatch_action( ); let token = generate_approval_token(run_id, step_id); - - // TODO (WF-08): create approval record in DB, emit kind:46010. - // For now, return Suspended with the token so the caller can persist state. + let timeout_secs = parse_duration_secs(timeout_str)?; + let expires_at = + chrono::Utc::now() + chrono::Duration::seconds(timeout_secs as i64); Ok(StepResult::Suspended { approval_token: token, + step_id: step_id.to_string(), + approver_spec: from.clone(), + expires_at, }) } @@ -969,6 +978,19 @@ async fn add_reaction_impl(message_id: &str, emoji: &str) -> Result, +} + /// Rich return type from `execute_run` / `execute_from_step`. /// /// Carries enough information for the caller to: @@ -979,7 +1001,7 @@ async fn add_reaction_impl(message_id: &str, emoji: &str) -> Result, + pub approval_gate: Option, /// Index of the step that suspended (or the total step count on completion). pub step_index: usize, /// Accumulated step outputs at the point of suspension or completion. @@ -1220,15 +1242,28 @@ async fn execute_steps( })); step_outputs.insert(step.id.clone(), output); } - StepResult::Suspended { approval_token } => { + StepResult::Suspended { + approval_token, + step_id, + approver_spec, + expires_at, + } => { info!( run_id = %run_id, step = %step.id, "Step suspended — awaiting approval (token: )" ); - // Return the token and current state so the caller can persist the - // approval record and update the run's execution trace. + trace.push(serde_json::json!({ + "step_id": step_id, + "status": "suspended", + "approver": approver_spec, + })); return Ok(ExecutionResult { - approval_token: Some(approval_token), + approval_gate: Some(ApprovalGate { + token: approval_token, + step_id, + approver_spec, + expires_at, + }), step_index: i, step_outputs, trace, @@ -1246,7 +1281,7 @@ async fn execute_steps( info!(run_id = %run_id, "Workflow run completed"); Ok(ExecutionResult { - approval_token: None, + approval_gate: None, step_index: def.steps.len(), step_outputs, trace, diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index fe8b477ba40..b72858389d4 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -37,7 +37,7 @@ pub mod schema; pub use action_sink::{ActionSink, ActionSinkError}; pub use error::{PartialProgress, WorkflowError}; -pub use executor::ExecutionResult; +pub use executor::{ApprovalGate, ExecutionResult}; pub use schema::{ActionDef, Step, TriggerDef, WorkflowDef}; use std::collections::HashMap; @@ -46,7 +46,7 @@ use std::sync::OnceLock; use buzz_core::kind::{event_kind_u32, is_workflow_execution_kind, KIND_REACTION}; use buzz_core::tenant::CommunityId; -use buzz_db::workflow::RunStatus; +use buzz_db::workflow::{CreateApprovalParams, RunStatus}; use buzz_db::Db; use chrono::{DateTime, Utc}; use dashmap::DashMap; @@ -226,32 +226,79 @@ impl WorkflowEngine { let trace_json = serde_json::Value::Array(full_trace); let step_count = result.step_index as i32; - if result.approval_token.is_some() { - // Approval gates are not yet implemented (WF-08). - // Fail explicitly rather than creating unreachable WaitingApproval rows. - tracing::warn!( + if let Some(gate) = result.approval_gate { + let run = match self.db.get_workflow_run(community_id, run_id).await { + Ok(run) => run, + Err(e) => { + tracing::error!( + run_id = %run_id, + "Failed to load run for approval gate: {e}" + ); + return; + } + }; + + if let Err(e) = self + .db + .create_approval(CreateApprovalParams { + community_id, + token: &gate.token, + workflow_id: run.workflow_id, + run_id, + step_id: &gate.step_id, + step_index: result.step_index as i32, + approver_spec: &gate.approver_spec, + expires_at: gate.expires_at, + }) + .await + { + tracing::error!( + run_id = %run_id, + "Failed to create approval record: {e}" + ); + if let Err(update_err) = self + .db + .update_workflow_run( + community_id, + run_id, + RunStatus::Failed, + step_count, + &trace_json, + Some(buzz_db::workflow::WorkflowRunFailure { + code: "approval_persist_failed", + message: "failed to persist approval gate", + }), + ) + .await + { + tracing::error!( + run_id = %run_id, + "Failed to update run after approval persist error: {update_err}" + ); + } + return; + } + + tracing::info!( run_id = %run_id, step_index = result.step_index, - "Workflow hit approval gate — not yet implemented, marking as failed" + "Workflow suspended — awaiting approval" ); if let Err(e) = self .db .update_workflow_run( community_id, run_id, - RunStatus::Failed, + RunStatus::WaitingApproval, step_count, &trace_json, - Some(buzz_db::workflow::WorkflowRunFailure { - code: "approval_not_supported", - message: "approval gates not yet implemented — see WF-08", - }), + None, ) .await { tracing::error!( run_id = %run_id, - "Failed to update run to Failed (approval gate): {e}" + "Failed to update run to WaitingApproval: {e}" ); } } else { diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 9bc79aa48b3..1448cc4ee4d 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -24,6 +24,9 @@ pub struct WorkflowDef { /// Whether this workflow is active. Defaults to `true`. #[serde(default = "default_true")] pub enabled: bool, + /// Flow Studio canvas id when exported from Flow Studio (`d` tag on kind 46200). + #[serde(default)] + pub flow_id: Option, } fn default_true() -> bool { @@ -81,6 +84,9 @@ pub struct Step { /// Maximum seconds this step may run before timing out. #[serde(default)] pub timeout_secs: Option, + /// Flow Studio block registry type when exported from canvas (e.g. `http`, `agent`). + #[serde(default)] + pub block_type: Option, /// The action to perform when this step executes. #[serde(flatten)] pub action: ActionDef, diff --git a/desktop/package.json b/desktop/package.json index 39e93d8a98d..9ba48d266aa 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -25,6 +25,7 @@ "tauri:build": "tauri build" }, "dependencies": { + "@xyflow/react": "^12.6.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7d06c4da91b..4150664b00f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -73,6 +73,7 @@ export default defineConfig({ "**/relay-reconnect.spec.ts", "**/relay-reconnect-affordance.spec.ts", "**/workflows.spec.ts", + "**/hive-studio.spec.ts", "**/identity-archive.spec.ts", "**/identity-archive-hide.spec.ts", "**/relay-connectivity.spec.ts", diff --git a/desktop/src-tauri/src/commands/hive_studio.rs b/desktop/src-tauri/src/commands/hive_studio.rs new file mode 100644 index 00000000000..944821a26d0 --- /dev/null +++ b/desktop/src-tauri/src/commands/hive_studio.rs @@ -0,0 +1,153 @@ +use tauri::State; + +use crate::{ + app_state::AppState, + events, + relay::{query_relay, submit_event}, +}; + +/// Publish a Flow Studio canvas graph (kind 46200). +#[tauri::command] +pub async fn publish_flow_graph( + flow_id: String, + graph_json: String, + state: State<'_, AppState>, +) -> Result { + let builder = events::build_flow_graph_saved(&flow_id, &graph_json)?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ + "accepted": result.accepted, + "event_id": result.event_id, + "message": format!("Flow graph '{flow_id}' published"), + })) +} + +/// Publish an Agent Studio skill import event (kind 47250). +#[tauri::command] +pub async fn publish_skill_import( + skill_id: String, + source_repo: Option, + source_commit: Option, + state: State<'_, AppState>, +) -> Result { + let builder = events::build_agent_skill_imported( + &skill_id, + source_repo.as_deref(), + source_commit.as_deref(), + )?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ + "accepted": result.accepted, + "event_id": result.event_id, + "message": format!("Skill '{skill_id}' import published"), + })) +} + +/// Load the latest saved Flow Studio graph for a flow id. +#[tauri::command] +pub async fn get_flow_graph( + flow_id: String, + state: State<'_, AppState>, +) -> Result { + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [46200], + "#d": [flow_id.clone()], + "limit": 1 + })], + ) + .await?; + + let Some(event) = events.first() else { + return Ok(serde_json::json!({ + "flow_id": flow_id, + "graph_json": null, + "found": false, + })); + }; + + let payload: serde_json::Value = + serde_json::from_str(&event.content).map_err(|e| format!("invalid graph payload: {e}"))?; + Ok(serde_json::json!({ + "flow_id": flow_id, + "graph_json": payload.get("graph_json").cloned().unwrap_or(serde_json::Value::Null), + "found": true, + "event_id": event.id.to_hex(), + })) +} + +/// Publish a knowledge-base document ingest event (kind 46250). +#[tauri::command] +pub async fn publish_kb_document( + knowledge_base_id: String, + document_id: String, + filename: String, + mime_type: String, + content: Option, + state: State<'_, AppState>, +) -> Result { + let builder = events::build_flow_kb_document_ingested( + &knowledge_base_id, + &document_id, + &filename, + &mime_type, + content.as_deref(), + )?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ + "accepted": result.accepted, + "event_id": result.event_id, + "message": format!("Document '{document_id}' ingested"), + })) +} + +/// Publish a Flow Studio table row (kind 46300). +#[tauri::command] +pub async fn publish_table_row( + table_id: String, + row_id: String, + row_json: String, + state: State<'_, AppState>, +) -> Result { + let builder = events::build_flow_table_row_created(&table_id, &row_id, &row_json)?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ + "accepted": result.accepted, + "event_id": result.event_id, + "message": format!("Row '{row_id}' saved in table '{table_id}'"), + })) +} + +/// Delete a Flow Studio table row (kind 46302). +#[tauri::command] +pub async fn delete_table_row( + table_id: String, + row_id: String, + state: State<'_, AppState>, +) -> Result { + let builder = events::build_flow_table_row_deleted(&table_id, &row_id)?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ + "accepted": result.accepted, + "event_id": result.event_id, + "message": format!("Row '{row_id}' deleted from table '{table_id}'"), + })) +} + +/// Publish Flow Studio file metadata (kind 46350). +#[tauri::command] +pub async fn publish_flow_file( + file_id: String, + filename: String, + media_url: Option, + state: State<'_, AppState>, +) -> Result { + let builder = events::build_flow_file_uploaded(&file_id, &filename, media_url.as_deref())?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ + "accepted": result.accepted, + "event_id": result.event_id, + "message": format!("File '{filename}' registered"), + })) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 761bee9cd32..ce6fb095792 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -20,6 +20,7 @@ mod dms; mod engrams; mod export_util; mod global_agent_config; +mod hive_studio; mod identity; mod identity_archive; mod join_policy; @@ -86,6 +87,7 @@ pub use clipboard::*; pub use dms::*; pub use engrams::*; pub use global_agent_config::*; +pub use hive_studio::*; pub use identity::*; pub use identity_archive::*; pub use join_policy::*; diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 89f2d1519ec..2eab0435101 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -152,10 +152,11 @@ pub(super) fn prepare_persona_publication_at( shared_override: Option, ) -> Result<(nostr::Event, RetainedEvent, AgentDefinition), String> { use crate::managed_agents::{ + agent_studio_events::build_agent_config_event, persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; - use buzz_core_pkg::kind::KIND_PERSONA; + use buzz_core_pkg::kind::{KIND_AGENT_CONFIG_CREATED, KIND_AGENT_CONFIG_UPDATED, KIND_PERSONA}; use nostr::JsonUtil; let d_tag = persona_d_tag(persona); @@ -179,14 +180,40 @@ pub(super) fn prepare_persona_publication_at( .map_err(|e| format!("failed to sign persona event: {e}"))?; let retained = RetainedEvent { kind: KIND_PERSONA, - pubkey, - d_tag, + pubkey: pubkey.clone(), + d_tag: d_tag.clone(), content: event.content.to_string(), created_at: event.created_at.as_secs() as i64, raw_event: event.as_json(), pending_sync: true, }; retain_event(&conn, &retained)?; + + let is_create = existing.is_none(); + let config_kind = if is_create { + KIND_AGENT_CONFIG_CREATED + } else { + KIND_AGENT_CONFIG_UPDATED + }; + let existing_config = get_retained_event(&conn, config_kind, &pubkey, &d_tag)?; + let config_builder = build_agent_config_event(&scoped_persona, is_create)?; + let config_event = config_builder + .custom_created_at(monotonic_created_at( + existing_config.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign agent config event: {e}"))?; + let config_retained = RetainedEvent { + kind: config_kind, + pubkey: pubkey.clone(), + d_tag: d_tag.clone(), + content: config_event.content.to_string(), + created_at: config_event.created_at.as_secs() as i64, + raw_event: config_event.as_json(), + pending_sync: true, + }; + retain_event(&conn, &config_retained)?; + Ok((event, retained, scoped_persona)) } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index df814afb36f..c1451828eef 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -12,8 +12,13 @@ use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; +mod canvas; +mod hive_studio; mod message_tags; +pub use canvas::*; +pub use hive_studio::*; + use message_tags::{ append_client_tags, append_sent_from_thread_tag, emoji_tags, imeta_tags, mention_reference_tags, }; @@ -413,15 +418,6 @@ pub fn build_remove_reaction(reaction_event_id: EventId) -> Result Result { - check_content(content)?; - let tags = vec![tag(vec!["h", &channel_id.to_string()])?]; - Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags)) -} - // ── Profile ────────────────────────────────────────────────────────────────── /// Kind 0 — NIP-01 profile metadata (full snapshot). @@ -787,14 +783,15 @@ pub fn build_workflow_trigger(workflow_id: &str) -> Result } /// Kind 46030 — grant an approval token (with optional note). -pub fn build_approval_grant(token: &str, note: Option<&str>) -> Result { - let tags = vec![tag(vec!["t", token])?]; +/// Kind 46030 — grant an approval (pass `approval_ref` = hex SHA-256 of token). +pub fn build_approval_grant(token_hash: &str, note: Option<&str>) -> Result { + let tags = vec![tag(vec!["d", token_hash])?]; Ok(EventBuilder::new(Kind::Custom(46030), note.unwrap_or("")).tags(tags)) } -/// Kind 46031 — deny an approval token (with optional note). -pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result { - let tags = vec![tag(vec!["t", token])?]; +/// Kind 46031 — deny an approval (pass `approval_ref` = hex SHA-256 of token). +pub fn build_approval_deny(token_hash: &str, note: Option<&str>) -> Result { + let tags = vec![tag(vec!["d", token_hash])?]; Ok(EventBuilder::new(Kind::Custom(46031), note.unwrap_or("")).tags(tags)) } diff --git a/desktop/src-tauri/src/events/canvas.rs b/desktop/src-tauri/src/events/canvas.rs new file mode 100644 index 00000000000..8c626840030 --- /dev/null +++ b/desktop/src-tauri/src/events/canvas.rs @@ -0,0 +1,27 @@ +//! Canvas event builders. + +use nostr::{EventBuilder, Kind, Tag}; +use uuid::Uuid; + +fn tag(parts: Vec<&str>) -> Result { + Tag::parse(parts).map_err(|e| format!("invalid tag: {e}")) +} + +fn check_content(content: &str) -> Result<(), String> { + const MAX_CONTENT_BYTES: usize = 64 * 1024; + if content.len() > MAX_CONTENT_BYTES { + return Err(format!( + "content exceeds maximum size of {} bytes (got {})", + MAX_CONTENT_BYTES, + content.len() + )); + } + Ok(()) +} + +/// Kind 40100 — set canvas. +pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result { + check_content(content)?; + let tags = vec![tag(vec!["h", &channel_id.to_string()])?]; + Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags)) +} diff --git a/desktop/src-tauri/src/events/hive_studio.rs b/desktop/src-tauri/src/events/hive_studio.rs new file mode 100644 index 00000000000..744c06838ff --- /dev/null +++ b/desktop/src-tauri/src/events/hive_studio.rs @@ -0,0 +1,142 @@ +//! Buzz Hive event builders (Flow Studio + Agent Studio kinds). + +use buzz_core_pkg::kind::{ + KIND_AGENT_SKILL_IMPORTED, KIND_FLOW_FILE_UPLOADED, KIND_FLOW_GRAPH_SAVED, + KIND_FLOW_KB_DOCUMENT_INGESTED, KIND_FLOW_TABLE_ROW_CREATED, KIND_FLOW_TABLE_ROW_DELETED, +}; +use nostr::{EventBuilder, Kind, Tag}; + +const MAX_CONTENT_BYTES: usize = 64 * 1024; + +fn tag(parts: Vec<&str>) -> Result { + Tag::parse(parts).map_err(|e| format!("invalid tag: {e}")) +} + +fn check_content(content: &str) -> Result<(), String> { + if content.len() > MAX_CONTENT_BYTES { + return Err(format!( + "content exceeds maximum size of {} bytes (got {})", + MAX_CONTENT_BYTES, + content.len() + )); + } + Ok(()) +} + +/// Kind 46200 — save a Flow Studio canvas graph (replaceable by `d` tag). +pub fn build_flow_graph_saved(flow_id: &str, graph_json: &str) -> Result { + if flow_id.trim().is_empty() { + return Err("flow_id is required".into()); + } + check_content(graph_json)?; + let payload = serde_json::json!({ + "flow_id": flow_id, + "graph_json": graph_json, + }); + let content = serde_json::to_string(&payload).map_err(|e| format!("serialize graph: {e}"))?; + let tags = vec![tag(vec!["d", flow_id])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_FLOW_GRAPH_SAVED as u16), content).tags(tags)) +} + +/// Kind 47250 — record a skill import from GitHub. +pub fn build_agent_skill_imported( + skill_id: &str, + source_repo: Option<&str>, + source_commit: Option<&str>, +) -> Result { + if skill_id.trim().is_empty() { + return Err("skill_id is required".into()); + } + let payload = serde_json::json!({ + "skill_id": skill_id, + "source_repo": source_repo, + "source_commit": source_commit, + }); + let content = serde_json::to_string(&payload).map_err(|e| format!("serialize skill: {e}"))?; + let tags = vec![tag(vec!["d", skill_id])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_AGENT_SKILL_IMPORTED as u16), content).tags(tags)) +} + +/// Kind 46250 — ingest a knowledge-base document. +pub fn build_flow_kb_document_ingested( + knowledge_base_id: &str, + document_id: &str, + filename: &str, + mime_type: &str, + content: Option<&str>, +) -> Result { + if knowledge_base_id.trim().is_empty() || document_id.trim().is_empty() { + return Err("knowledge_base_id and document_id are required".into()); + } + let payload = serde_json::json!({ + "knowledge_base_id": knowledge_base_id, + "document_id": document_id, + "filename": filename, + "mime_type": mime_type, + "content": content, + }); + let content_json = + serde_json::to_string(&payload).map_err(|e| format!("serialize kb document: {e}"))?; + let tags = vec![tag(vec!["d", document_id])?]; + Ok(EventBuilder::new( + Kind::Custom(KIND_FLOW_KB_DOCUMENT_INGESTED as u16), + content_json, + ) + .tags(tags)) +} + +/// Kind 46300 — create or update a Flow Studio table row. +pub fn build_flow_table_row_created( + table_id: &str, + row_id: &str, + row_json: &str, +) -> Result { + if table_id.trim().is_empty() || row_id.trim().is_empty() { + return Err("table_id and row_id are required".into()); + } + check_content(row_json)?; + let payload = serde_json::json!({ + "table_id": table_id, + "row_id": row_id, + "row_json": row_json, + }); + let content = + serde_json::to_string(&payload).map_err(|e| format!("serialize table row: {e}"))?; + let tags = vec![tag(vec!["d", &format!("{table_id}:{row_id}")])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_FLOW_TABLE_ROW_CREATED as u16), content).tags(tags)) +} + +/// Kind 46302 — delete a Flow Studio table row. +pub fn build_flow_table_row_deleted(table_id: &str, row_id: &str) -> Result { + if table_id.trim().is_empty() || row_id.trim().is_empty() { + return Err("table_id and row_id are required".into()); + } + let payload = serde_json::json!({ + "table_id": table_id, + "row_id": row_id, + }); + let content = + serde_json::to_string(&payload).map_err(|e| format!("serialize table delete: {e}"))?; + let tags = vec![tag(vec!["d", &format!("{table_id}:{row_id}")])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_FLOW_TABLE_ROW_DELETED as u16), content).tags(tags)) +} + +/// Kind 46350 — register Flow Studio file metadata (bytes via Buzz media). +pub fn build_flow_file_uploaded( + file_id: &str, + filename: &str, + media_url: Option<&str>, +) -> Result { + if file_id.trim().is_empty() || filename.trim().is_empty() { + return Err("file_id and filename are required".into()); + } + let payload = serde_json::json!({ + "file_id": file_id, + "filename": filename, + "media_url": media_url, + }); + let content = + serde_json::to_string(&payload).map_err(|e| format!("serialize file metadata: {e}"))?; + let tags = vec![tag(vec!["d", file_id])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_FLOW_FILE_UPLOADED as u16), content).tags(tags)) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6f3f48f3a84..054f4660b3c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -88,11 +88,7 @@ use tauri_plugin_window_state::StateFlags; use tray_menu::show_main_window; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // mesh-llm's async chains (model download, node start/join) overflow - // tokio's default 2 MiB worker stacks — a stack-guard SIGABRT, not a - // panic. Upstream mesh-llm and mesh-console both run on 8 MiB worker - // stacks for this reason; give Tauri's command runtime the same headroom - // before anything else touches tauri::async_runtime. + // mesh-llm async chains overflow 2 MiB tokio stacks; install 8 MiB workers first. #[cfg(feature = "mesh-llm")] match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -101,8 +97,6 @@ pub fn run() { { Ok(runtime) => { tauri::async_runtime::set(runtime.handle().clone()); - // Keep the runtime alive for the process lifetime; dropping it - // would shut down the workers Tauri now depends on. std::mem::forget(runtime); eprintln!( "buzz-mesh: installed tokio runtime with {} MiB worker stacks", @@ -110,8 +104,6 @@ pub fn run() { ); } Err(error) => { - // Fall back to Tauri's default runtime: the app still works, - // only deep mesh-llm futures are at risk of stack overflow. eprintln!("buzz-mesh: failed to build big-stack tokio runtime, using default: {error}"); } } @@ -712,6 +704,13 @@ pub fn run() { leave_channel, get_canvas, set_canvas, + publish_flow_graph, + get_flow_graph, + publish_skill_import, + publish_kb_document, + publish_table_row, + delete_table_row, + publish_flow_file, get_feed, search_messages, send_channel_message, diff --git a/desktop/src-tauri/src/managed_agents/agent_studio_events.rs b/desktop/src-tauri/src/managed_agents/agent_studio_events.rs new file mode 100644 index 00000000000..1e8570ff188 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_studio_events.rs @@ -0,0 +1,35 @@ +//! Build kind 47200/47201 Agent Studio config events alongside persona publishes. + +use buzz_core_pkg::kind::{KIND_AGENT_CONFIG_CREATED, KIND_AGENT_CONFIG_UPDATED}; +use nostr::{EventBuilder, Kind, Tag}; +use serde::Serialize; + +use super::{persona_events::persona_d_tag, AgentDefinition}; + +/// JSON body for [`KIND_AGENT_CONFIG_CREATED`] / [`KIND_AGENT_CONFIG_UPDATED`]. +#[derive(Debug, Clone, Serialize)] +struct AgentConfigPayload<'a> { + agent_id: &'a str, + config_json: String, +} + +/// Build a replaceable Agent Studio config event for the given persona record. +pub fn build_agent_config_event( + persona: &AgentDefinition, + is_create: bool, +) -> Result { + let agent_id = persona_d_tag(persona); + let config_json = serde_json::to_string(persona) + .map_err(|e| format!("failed to serialize persona for agent config: {e}"))?; + let content = serde_json::to_string(&AgentConfigPayload { + agent_id: &agent_id, + config_json, + }) + .map_err(|e| format!("failed to serialize agent config payload: {e}"))?; + let kind = if is_create { + KIND_AGENT_CONFIG_CREATED + } else { + KIND_AGENT_CONFIG_UPDATED + }; + Ok(EventBuilder::new(Kind::Custom(kind as u16), content).tags(vec![Tag::identifier(agent_id)])) +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c6ccd3709c0..2b4237718ef 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -8,6 +8,7 @@ pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_ac pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; +pub(crate) mod agent_studio_events; mod backend; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index dd6b9195e82..a9e969a89af 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -9,7 +9,9 @@ export type AppView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "flow-studio" + | "agent-studio"; const WINDOW_DRAG_HANDLE_HEIGHT = 44; const TAURI_DRAG_REGION_ATTR = "data-tauri-drag-region"; @@ -188,6 +190,20 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/flow-studio" || pathname.startsWith("/flow-studio/")) { + return { + selectedChannelId: null, + selectedView: "flow-studio", + }; + } + + if (pathname === "/agent-studio" || pathname.startsWith("/agent-studio/")) { + return { + selectedChannelId: null, + selectedView: "agent-studio", + }; + } + return { selectedChannelId: null, selectedView: "home", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b720..83911723795 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -141,6 +141,8 @@ export function AppShell() { goHome, goNewMessage, goProjects, + goFlowStudio, + goAgentStudio, goPulse, goSettings, goWorkflows, @@ -857,6 +859,8 @@ export function AppShell() { ]} onSelectHome={() => void goHome()} onSelectProjects={() => void goProjects()} + onSelectFlowStudio={() => void goFlowStudio()} + onSelectAgentStudio={() => void goAgentStudio()} onSelectPulse={() => void goPulse()} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2203aa03a6a..7db340b0b8f 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -104,6 +104,18 @@ export function useAppNavigation() { [commitNavigation], ); + const goFlowStudio = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation({ to: "/flow-studio" }, behavior), + [commitNavigation], + ); + + const goAgentStudio = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation({ to: "/agent-studio" }, behavior), + [commitNavigation], + ); + const goProject = React.useCallback( ( projectId: string, @@ -335,6 +347,8 @@ export function useAppNavigation() { goNewMessage, goProject, goProjects, + goFlowStudio, + goAgentStudio, goPulse, goProfile, goSettings, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6d..7b5675e092a 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -10,7 +10,9 @@ import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; +import { Route as flowStudioRouteImport } from "./routes/flow-studio"; import { Route as agentsRouteImport } from "./routes/agents"; +import { Route as agentStudioRouteImport } from "./routes/agent-studio"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; import { Route as projectsDotprojectIdRouteImport } from "./routes/projects.$projectId"; @@ -43,11 +45,21 @@ const projectsRoute = projectsRouteImport.update({ path: "/projects", getParentRoute: () => rootRouteImport, } as any); +const flowStudioRoute = flowStudioRouteImport.update({ + id: "/flow-studio", + path: "/flow-studio", + getParentRoute: () => rootRouteImport, +} as any); const agentsRoute = agentsRouteImport.update({ id: "/agents", path: "/agents", getParentRoute: () => rootRouteImport, } as any); +const agentStudioRoute = agentStudioRouteImport.update({ + id: "/agent-studio", + path: "/agent-studio", + getParentRoute: () => rootRouteImport, +} as any); const indexRoute = indexRouteImport.update({ id: "/", path: "/", @@ -82,7 +94,9 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; + "/agent-studio": typeof agentStudioRoute; "/agents": typeof agentsRoute; + "/flow-studio": typeof flowStudioRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -96,7 +110,9 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { "/": typeof indexRoute; + "/agent-studio": typeof agentStudioRoute; "/agents": typeof agentsRoute; + "/flow-studio": typeof flowStudioRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -111,7 +127,9 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; + "/agent-studio": typeof agentStudioRoute; "/agents": typeof agentsRoute; + "/flow-studio": typeof flowStudioRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -127,7 +145,9 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; fullPaths: | "/" + | "/agent-studio" | "/agents" + | "/flow-studio" | "/projects" | "/pulse" | "/reminders" @@ -141,7 +161,9 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo; to: | "/" + | "/agent-studio" | "/agents" + | "/flow-studio" | "/projects" | "/pulse" | "/reminders" @@ -155,7 +177,9 @@ export interface FileRouteTypes { id: | "__root__" | "/" + | "/agent-studio" | "/agents" + | "/flow-studio" | "/projects" | "/pulse" | "/reminders" @@ -170,7 +194,9 @@ export interface FileRouteTypes { } export interface RootRouteChildren { indexRoute: typeof indexRoute; + agentStudioRoute: typeof agentStudioRoute; agentsRoute: typeof agentsRoute; + flowStudioRoute: typeof flowStudioRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; @@ -220,6 +246,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsRouteImport; parentRoute: typeof rootRouteImport; }; + "/flow-studio": { + id: "/flow-studio"; + path: "/flow-studio"; + fullPath: "/flow-studio"; + preLoaderRoute: typeof flowStudioRouteImport; + parentRoute: typeof rootRouteImport; + }; "/agents": { id: "/agents"; path: "/agents"; @@ -227,6 +260,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof agentsRouteImport; parentRoute: typeof rootRouteImport; }; + "/agent-studio": { + id: "/agent-studio"; + path: "/agent-studio"; + fullPath: "/agent-studio"; + preLoaderRoute: typeof agentStudioRouteImport; + parentRoute: typeof rootRouteImport; + }; "/": { id: "/"; path: "/"; @@ -274,7 +314,9 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, + agentStudioRoute: agentStudioRoute, agentsRoute: agentsRoute, + flowStudioRoute: flowStudioRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11a..6ea294ea874 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -10,6 +10,8 @@ export const routes = rootRoute("root.tsx", [ route("/workflows/$workflowId", "workflows.$workflowId.tsx"), route("/projects", "projects.tsx"), route("/projects/$projectId", "projects.$projectId.tsx"), + route("/flow-studio", "flow-studio.tsx"), + route("/agent-studio", "agent-studio.tsx"), route("/messages/new", "messages.new.tsx"), route("/channels/$channelId", "channels.$channelId.tsx"), route( diff --git a/desktop/src/app/routes/agent-studio.tsx b/desktop/src/app/routes/agent-studio.tsx new file mode 100644 index 00000000000..6cfb581b6f3 --- /dev/null +++ b/desktop/src/app/routes/agent-studio.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { usePreviewFeatureWarning } from "@/shared/features"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const AgentStudioScreen = React.lazy(async () => { + const module = await import("@/features/agent-studio/ui/AgentStudioScreen"); + return { default: module.AgentStudioScreen }; +}); + +export const Route = createFileRoute("/agent-studio")({ + component: AgentStudioRouteComponent, +}); + +function AgentStudioRouteComponent() { + usePreviewFeatureWarning("agent-studio"); + return ( + }> + + + ); +} diff --git a/desktop/src/app/routes/flow-studio.tsx b/desktop/src/app/routes/flow-studio.tsx new file mode 100644 index 00000000000..5b2f7881535 --- /dev/null +++ b/desktop/src/app/routes/flow-studio.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { usePreviewFeatureWarning } from "@/shared/features"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const FlowStudioScreen = React.lazy(async () => { + const module = await import("@/features/flow-studio/ui/FlowStudioScreen"); + return { default: module.FlowStudioScreen }; +}); + +export const Route = createFileRoute("/flow-studio")({ + component: FlowStudioRouteComponent, +}); + +function FlowStudioRouteComponent() { + usePreviewFeatureWarning("flow-studio"); + return ( + }> + + + ); +} diff --git a/desktop/src/features/agent-studio/ui/AgentGraph.tsx b/desktop/src/features/agent-studio/ui/AgentGraph.tsx new file mode 100644 index 00000000000..a9c743fa396 --- /dev/null +++ b/desktop/src/features/agent-studio/ui/AgentGraph.tsx @@ -0,0 +1,81 @@ +import { + Background, + Controls, + type Edge, + type Node, + ReactFlow, + useEdgesState, + useNodesState, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import * as React from "react"; + +type GraphNode = { + id: string; + kind: string; + slug: string; +}; + +type GraphEdge = { + source_type: string; + source_slug: string; + target_type: string; + target_slug: string; + relationship_type: string; + evidence: string; +}; + +function toFlowNodes(nodes: GraphNode[]): Node[] { + return nodes.map((node, index) => ({ + id: node.id, + data: { label: `${node.kind}: ${node.slug}` }, + position: { x: (index % 4) * 180, y: Math.floor(index / 4) * 100 }, + })); +} + +function toFlowEdges(edges: GraphEdge[]): Edge[] { + return edges.map((edge, index) => ({ + id: `e-${index}-${edge.source_slug}-${edge.target_slug}`, + source: `${edge.source_type}:${edge.source_slug}`, + target: `${edge.target_type}:${edge.target_slug}`, + label: edge.relationship_type, + })); +} + +type AgentGraphProps = { + nodes: GraphNode[]; + edges: GraphEdge[]; +}; + +export function AgentGraph({ nodes, edges }: AgentGraphProps) { + const [flowNodes, setNodes, onNodesChange] = useNodesState( + toFlowNodes(nodes), + ); + const [flowEdges, setEdges, onEdgesChange] = useEdgesState( + toFlowEdges(edges), + ); + + React.useEffect(() => { + setNodes(toFlowNodes(nodes)); + setEdges(toFlowEdges(edges)); + }, [nodes, edges, setNodes, setEdges]); + + if (nodes.length === 0) { + return null; + } + + return ( +
+ + + + +
+ ); +} diff --git a/desktop/src/features/agent-studio/ui/AgentStudioScreen.tsx b/desktop/src/features/agent-studio/ui/AgentStudioScreen.tsx new file mode 100644 index 00000000000..273fb6469fb --- /dev/null +++ b/desktop/src/features/agent-studio/ui/AgentStudioScreen.tsx @@ -0,0 +1,9 @@ +import { AgentStudioView } from "@/features/agent-studio/ui/AgentStudioView"; + +export function AgentStudioScreen() { + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/agent-studio/ui/AgentStudioView.tsx b/desktop/src/features/agent-studio/ui/AgentStudioView.tsx new file mode 100644 index 00000000000..02eb137d9b2 --- /dev/null +++ b/desktop/src/features/agent-studio/ui/AgentStudioView.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import { Bot, Download, Network } from "lucide-react"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { Button } from "@/shared/ui/button"; + +import { AgentGraph } from "./AgentGraph"; +import { SkillImportModal } from "./SkillImportModal"; +import { UnifiedCostMonitor } from "./UnifiedCostMonitor"; + +type GraphNode = { + id: string; + kind: string; + slug: string; +}; + +type GraphEdge = { + source_type: string; + source_slug: string; + target_type: string; + target_slug: string; + relationship_type: string; + evidence: string; +}; + +export function AgentStudioView() { + const { activeCommunity } = useCommunities(); + const [nodes, setNodes] = React.useState([]); + const [edges, setEdges] = React.useState([]); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [importOpen, setImportOpen] = React.useState(false); + + React.useEffect(() => { + let cancelled = false; + const relayHttp = activeCommunity?.relayUrl?.replace(/^ws/i, "http"); + if (!relayHttp) { + setLoading(false); + return; + } + + void (async () => { + try { + const res = await fetch(`${relayHttp}/agent-studio/graph`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const data = (await res.json()) as { + nodes?: GraphNode[]; + edges?: GraphEdge[]; + }; + if (!cancelled) { + setNodes(data.nodes ?? []); + setEdges(data.edges ?? []); + setError(null); + } + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : "Failed to load graph"); + setNodes([]); + setEdges([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [activeCommunity?.relayUrl]); + + return ( +
+
+
+ +

Agent Studio

+
+ +
+

+ Dependency graph for personas, commands, and skills — ported from + claude-code-cli-ui. Events persist via Nostr kinds 47200–47399. +

+ +
+
+ + Dependency graph +
+ {loading ? ( +

Loading graph…

+ ) : null} + {error ? ( +

+ Could not load graph from relay: {error} +

+ ) : null} + {!loading && !error && nodes.length === 0 && edges.length === 0 ? ( +

+ No agents scanned yet. Import skills or create personas to populate + the graph. +

+ ) : null} + {nodes.length > 0 ? ( +

+ {nodes.length} nodes · {edges.length} edges +

+ ) : null} + +
+ + + + +
+ ); +} diff --git a/desktop/src/features/agent-studio/ui/SessionMonitor.tsx b/desktop/src/features/agent-studio/ui/SessionMonitor.tsx new file mode 100644 index 00000000000..aa82ab1a881 --- /dev/null +++ b/desktop/src/features/agent-studio/ui/SessionMonitor.tsx @@ -0,0 +1,123 @@ +import * as React from "react"; +import { Activity } from "lucide-react"; + +import { useCommunities } from "@/features/communities/useCommunities"; + +type SessionRow = { + session_id: string; + agent_id?: string | null; + input_tokens: number; + output_tokens: number; + cost_usd: number; + tool_calls: number; +}; + +type SessionMonitorProps = { + /** When true, omit outer section chrome (used inside UnifiedCostMonitor). */ + embedded?: boolean; +}; + +export function SessionMonitor({ embedded = false }: SessionMonitorProps) { + const { activeCommunity } = useCommunities(); + const [sessions, setSessions] = React.useState([]); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + const relayHttp = activeCommunity?.relayUrl?.replace(/^ws/i, "http"); + if (!relayHttp) return; + + const load = () => { + void fetch(`${relayHttp}/agent-studio/sessions`) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data: { sessions?: SessionRow[] }) => { + if (!cancelled) { + setSessions(data.sessions ?? []); + setError(null); + } + }) + .catch((e: unknown) => { + if (!cancelled) { + setError( + e instanceof Error ? e.message : "Failed to load sessions", + ); + } + }); + }; + + load(); + const id = window.setInterval(load, 5000); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [activeCommunity?.relayUrl]); + + const totalCost = sessions.reduce((sum, s) => sum + s.cost_usd, 0); + const totalTokens = sessions.reduce( + (sum, s) => sum + s.input_tokens + s.output_tokens, + 0, + ); + + const body = ( + <> + {error ?

{error}

: null} + {sessions.length === 0 && !error ? ( +

No active sessions.

+ ) : null} + {sessions.length > 0 ? ( + + + + + + + + + + + {sessions.map((s) => ( + + + + + + + ))} + +
SessionAgentTokensCost
{s.session_id}{s.agent_id ?? "—"}{s.input_tokens + s.output_tokens}${s.cost_usd.toFixed(4)}
+ ) : null} + + ); + + if (embedded) { + return ( +
+
+ + Sessions + + ${totalCost.toFixed(4)} · {totalTokens.toLocaleString()} tokens + +
+ {body} +
+ ); + } + + return ( +
+
+ + Session monitor + + ${totalCost.toFixed(4)} total + +
+ {body} +
+ ); +} diff --git a/desktop/src/features/agent-studio/ui/SkillImportModal.tsx b/desktop/src/features/agent-studio/ui/SkillImportModal.tsx new file mode 100644 index 00000000000..a46c950bb64 --- /dev/null +++ b/desktop/src/features/agent-studio/ui/SkillImportModal.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import { Download } from "lucide-react"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { publishSkillImport } from "@/shared/api/tauriHiveStudio"; +import { Button } from "@/shared/ui/button"; + +type SkillImportModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function SkillImportModal({ + open, + onOpenChange, +}: SkillImportModalProps) { + const { activeCommunity } = useCommunities(); + const [repo, setRepo] = React.useState(""); + const [skillId, setSkillId] = React.useState(""); + const [message, setMessage] = React.useState(null); + const [loading, setLoading] = React.useState(false); + + React.useEffect(() => { + if (!open) { + setMessage(null); + } + }, [open]); + + if (!open) { + return null; + } + + const relayHttp = activeCommunity?.relayUrl?.replace(/^ws/i, "http"); + + return ( +
+
+

Import skill from GitHub

+

+ Imports a skill from GitHub and publishes kind 47250 to the relay. +

+ + + {message ? ( +

{message}

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/desktop/src/features/agent-studio/ui/UnifiedCostMonitor.tsx b/desktop/src/features/agent-studio/ui/UnifiedCostMonitor.tsx new file mode 100644 index 00000000000..d626c756d20 --- /dev/null +++ b/desktop/src/features/agent-studio/ui/UnifiedCostMonitor.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import { DollarSign } from "lucide-react"; + +import { useCommunities } from "@/features/communities/useCommunities"; + +type CostSummary = { + total_cost_usd: number; + acp_session_cost_usd: number; + flow_block_cost_usd: number; + total_tokens: number; + session_count: number; + sessions: Array<{ + session_id: string; + agent_id?: string | null; + input_tokens: number; + output_tokens: number; + cost_usd: number; + }>; +}; + +/** Unified cost dashboard — ACP sessions plus Flow block execution costs. */ +export function UnifiedCostMonitor() { + const { activeCommunity } = useCommunities(); + const [summary, setSummary] = React.useState(null); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + const relayHttp = activeCommunity?.relayUrl?.replace(/^ws/i, "http"); + if (!relayHttp) return; + + const load = () => { + void fetch(`${relayHttp}/agent-studio/costs`) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data: CostSummary) => { + if (!cancelled) { + setSummary(data); + setError(null); + } + }) + .catch((e: unknown) => { + if (!cancelled) { + setError(e instanceof Error ? e.message : "Failed to load costs"); + } + }); + }; + + load(); + const id = window.setInterval(load, 5000); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [activeCommunity?.relayUrl]); + + return ( +
+
+ + Cost monitor + {summary ? ( + + ${summary.total_cost_usd.toFixed(4)} total ·{" "} + {summary.total_tokens.toLocaleString()} tokens ·{" "} + {summary.session_count} sessions + + ) : null} +
+ {summary ? ( +
+

+ ACP sessions: ${summary.acp_session_cost_usd.toFixed(4)} +

+

+ Flow blocks: ${summary.flow_block_cost_usd.toFixed(4)} +

+
+ ) : null} + {error ?

{error}

: null} + {!summary && !error ? ( +

Loading costs…

+ ) : null} + {summary && summary.sessions.length > 0 ? ( + + + + + + + + + + + {summary.sessions.map((session) => ( + + + + + + + ))} + +
SessionAgentTokensCost
+ {session.session_id} + {session.agent_id ?? "—"} + {session.input_tokens + session.output_tokens} + ${session.cost_usd.toFixed(4)}
+ ) : summary ? ( +

No active sessions.

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/flow-studio/ui/BlockPalette.tsx b/desktop/src/features/flow-studio/ui/BlockPalette.tsx new file mode 100644 index 00000000000..7f235b623df --- /dev/null +++ b/desktop/src/features/flow-studio/ui/BlockPalette.tsx @@ -0,0 +1,40 @@ +type FlowBlock = { + block_type: string; + name: string; + description: string; + category: string; +}; + +type BlockPaletteProps = { + blocks: FlowBlock[]; +}; + +export function BlockPalette({ blocks }: BlockPaletteProps) { + return ( +
    + {blocks.map((block) => ( +
  • { + event.dataTransfer.setData( + "application/buzz-flow-block", + JSON.stringify(block), + ); + }} + > +

    {block.name}

    +

    + {block.description} +

    +

    + {block.category} +

    +
  • + ))} +
+ ); +} + +export type { FlowBlock }; diff --git a/desktop/src/features/flow-studio/ui/Canvas.tsx b/desktop/src/features/flow-studio/ui/Canvas.tsx new file mode 100644 index 00000000000..5cfd6c89cc0 --- /dev/null +++ b/desktop/src/features/flow-studio/ui/Canvas.tsx @@ -0,0 +1,133 @@ +import { + Background, + Controls, + type Node, + ReactFlow, + useNodesState, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import * as React from "react"; + +import type { FlowBlock } from "./BlockPalette"; + +export type CanvasNodeData = { + label: string; + blockType: string; + status?: string; +}; + +export type FlowCanvasNode = Node; + +export type FlowCanvasProps = { + nodes: FlowCanvasNode[]; + onNodesChange: ReturnType>[2]; + setNodes: ReturnType>[1]; + nodeStatuses?: Record; +}; + +export function useFlowCanvasState(initial: FlowCanvasNode[] = []) { + return useNodesState(initial); +} + +export function FlowCanvas({ + nodes, + onNodesChange, + setNodes, + nodeStatuses = {}, +}: FlowCanvasProps) { + const displayNodes = React.useMemo( + () => + nodes.map((node) => ({ + ...node, + data: { + ...node.data, + label: nodeStatuses[node.id] + ? `${node.data.label} (${nodeStatuses[node.id]})` + : node.data.label, + }, + style: nodeStyle(nodeStatuses[node.id]), + })), + [nodes, nodeStatuses], + ); + + const onDrop = React.useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + const raw = event.dataTransfer.getData("application/buzz-flow-block"); + if (!raw) return; + const block = JSON.parse(raw) as FlowBlock; + const id = `step-${nodes.length + 1}`; + setNodes((prev) => [ + ...prev, + { + id, + data: { label: block.name, blockType: block.block_type }, + position: { x: 80 + prev.length * 40, y: 80 + prev.length * 30 }, + }, + ]); + }, + [nodes.length, setNodes], + ); + + return ( +
+
e.preventDefault()} + onDrop={onDrop} + role="application" + > + + + + +
+ {nodes.length === 0 ? ( +

+ Drag blocks from the palette +

+ ) : null} +
+ ); +} + +function nodeStyle(status?: string): React.CSSProperties | undefined { + if (!status) return undefined; + const colors: Record = { + completed: "#22c55e33", + failed: "#ef444433", + error: "#ef444433", + running: "#3b82f633", + suspended: "#f59e0b33", + waiting_approval: "#f59e0b33", + }; + const border = colors[status]; + return border ? { backgroundColor: border, borderRadius: 8 } : undefined; +} + +export function canvasNodesToBlocks(nodes: FlowCanvasNode[]) { + return nodes.map((node) => ({ + id: node.id, + block_type: node.data.blockType, + config_json: + node.data.blockType === "http" + ? { url: "https://example.com/hook" } + : node.data.blockType === "human_approval" + ? { from: "@anyone", message: "Approve this step?" } + : {}, + })); +} + +export function serializeGraph(nodes: FlowCanvasNode[]) { + return JSON.stringify({ nodes, edges: [] }); +} + +export function parseGraph(graphJson: string): FlowCanvasNode[] { + try { + const parsed = JSON.parse(graphJson) as { nodes?: FlowCanvasNode[] }; + return Array.isArray(parsed.nodes) ? parsed.nodes : []; + } catch { + return []; + } +} diff --git a/desktop/src/features/flow-studio/ui/FilesPanel.tsx b/desktop/src/features/flow-studio/ui/FilesPanel.tsx new file mode 100644 index 00000000000..72b6ebfde46 --- /dev/null +++ b/desktop/src/features/flow-studio/ui/FilesPanel.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { publishFlowFile } from "@/shared/api/tauriHiveStudio"; +import { Button } from "@/shared/ui/button"; + +export function FilesPanel() { + const { activeCommunity } = useCommunities(); + const [filename, setFilename] = React.useState(""); + const [mediaUrl, setMediaUrl] = React.useState(""); + const [message, setMessage] = React.useState(null); + + const relayHttp = activeCommunity?.relayUrl?.replace(/^ws/i, "http"); + + const filesQuery = useQuery({ + enabled: Boolean(relayHttp), + queryFn: async () => { + const res = await fetch(`${relayHttp}/flow-studio/files`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return (await res.json()) as { + files?: Array<{ + file_id: string; + filename: string; + media_url?: string | null; + version: number; + }>; + }; + }, + queryKey: ["flow-files", relayHttp], + refetchInterval: 5000, + }); + + const registerFile = () => { + if (!filename.trim()) return; + const fileId = `file-${Date.now()}`; + void publishFlowFile( + fileId, + filename, + mediaUrl.trim() ? mediaUrl.trim() : null, + ) + .then((result) => { + setMessage(result.message); + setFilename(""); + setMediaUrl(""); + void filesQuery.refetch(); + }) + .catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : "Register failed"); + }); + }; + + return ( +
+

Files

+

+ Metadata via kind 46350; upload bytes through Buzz media, then paste the + media URL here. +

+ + + + {message ? ( +

{message}

+ ) : null} +
    + {(filesQuery.data?.files ?? []).map((file) => ( +
  • +
    {file.filename}
    +
    + v{file.version} · {file.file_id} +
    + {file.media_url ? ( + + {file.media_url} + + ) : null} +
  • + ))} +
+
+ ); +} diff --git a/desktop/src/features/flow-studio/ui/FlowStudioScreen.tsx b/desktop/src/features/flow-studio/ui/FlowStudioScreen.tsx new file mode 100644 index 00000000000..efc89101c3a --- /dev/null +++ b/desktop/src/features/flow-studio/ui/FlowStudioScreen.tsx @@ -0,0 +1,9 @@ +import { FlowStudioView } from "@/features/flow-studio/ui/FlowStudioView"; + +export function FlowStudioScreen() { + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/flow-studio/ui/FlowStudioView.tsx b/desktop/src/features/flow-studio/ui/FlowStudioView.tsx new file mode 100644 index 00000000000..16609131633 --- /dev/null +++ b/desktop/src/features/flow-studio/ui/FlowStudioView.tsx @@ -0,0 +1,265 @@ +import * as React from "react"; +import { GitBranch, Layers, Play, Save } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { WorkflowApprovalCard } from "@/features/workflows/ui/WorkflowApprovalCard"; +import { getFlowGraph, publishFlowGraph } from "@/shared/api/tauriHiveStudio"; +import { + createWorkflow, + getRunApprovals, + getWorkflowRuns, + triggerWorkflow, +} from "@/shared/api/tauriWorkflows"; +import { Button } from "@/shared/ui/button"; + +import { BlockPalette, type FlowBlock } from "./BlockPalette"; +import { + canvasNodesToBlocks, + FlowCanvas, + parseGraph, + serializeGraph, + useFlowCanvasState, +} from "./Canvas"; +import { FilesPanel } from "./FilesPanel"; +import { KnowledgeBasePanel } from "./KnowledgeBasePanel"; +import { TablesPanel } from "./TablesPanel"; + +export function FlowStudioView() { + const { activeCommunity } = useCommunities(); + const channelsQuery = useChannelsQuery(); + const [blocks, setBlocks] = React.useState([]); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [statusMessage, setStatusMessage] = React.useState(null); + const [workflowId, setWorkflowId] = React.useState(null); + const flowId = workflowId ?? "flow-studio-draft"; + const [activeRunId, setActiveRunId] = React.useState(null); + const [nodes, setNodes, onNodesChange] = useFlowCanvasState(); + + const channelId = + channelsQuery.data?.find((c) => c.name === "general")?.id ?? + channelsQuery.data?.[0]?.id ?? + null; + + React.useEffect(() => { + let cancelled = false; + const relayHttp = activeCommunity?.relayUrl?.replace(/^ws/i, "http"); + if (!relayHttp) { + setLoading(false); + setBlocks([]); + return; + } + + void (async () => { + try { + const res = await fetch(`${relayHttp}/flow-studio/blocks`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const data = (await res.json()) as { blocks?: FlowBlock[] }; + if (!cancelled) { + setBlocks(data.blocks ?? []); + setError(null); + } + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : "Failed to load blocks"); + setBlocks([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [activeCommunity?.relayUrl]); + + React.useEffect(() => { + let cancelled = false; + void getFlowGraph(flowId) + .then((saved) => { + if (cancelled || !saved.found || !saved.graph_json) { + return; + } + const restored = parseGraph(saved.graph_json); + if (restored.length > 0) { + setNodes(restored); + } + }) + .catch(() => { + // No saved graph yet — start from empty canvas. + }); + return () => { + cancelled = true; + }; + }, [flowId, setNodes]); + + const runsQuery = useQuery({ + enabled: Boolean(workflowId), + queryFn: () => getWorkflowRuns(workflowId as string, 5), + queryKey: ["workflow-runs", workflowId], + refetchInterval: (query) => { + const runs = query.state.data; + const active = runs?.some( + (run) => + run.status === "pending" || + run.status === "running" || + run.status === "waiting_approval", + ); + return active ? 1000 : false; + }, + }); + + const approvalsQuery = useQuery({ + enabled: Boolean(workflowId && activeRunId), + queryFn: () => getRunApprovals(workflowId as string, activeRunId as string), + queryKey: ["run-approvals", workflowId, activeRunId], + refetchInterval: 5000, + }); + + const activeRun = runsQuery.data?.[0]; + React.useEffect(() => { + if (activeRun?.id) { + setActiveRunId(activeRun.id); + } + }, [activeRun?.id]); + + const nodeStatuses = React.useMemo(() => { + const map: Record = {}; + for (const step of activeRun?.executionTrace ?? []) { + map[step.stepId] = step.status; + } + return map; + }, [activeRun?.executionTrace]); + + const relayHttp = activeCommunity?.relayUrl?.replace(/^ws/i, "http"); + + const saveGraph = () => { + if (nodes.length === 0) return; + void publishFlowGraph(flowId, serializeGraph(nodes)) + .then((data) => { + setStatusMessage(data.message ?? "Graph saved"); + }) + .catch((e: unknown) => { + setStatusMessage(e instanceof Error ? e.message : "Save failed"); + }); + }; + + const runFlow = async () => { + if (!relayHttp || !channelId || nodes.length === 0) { + setStatusMessage("Add canvas blocks and connect to a channel first"); + return; + } + const canvasBlocks = canvasNodesToBlocks(nodes); + const yamlRes = await fetch(`${relayHttp}/flow-studio/yaml/from-canvas`, { + body: JSON.stringify({ blocks: canvasBlocks, flow_id: flowId }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const yamlData = (await yamlRes.json()) as { + yaml?: string; + error?: string; + }; + if (yamlData.error || !yamlData.yaml) { + setStatusMessage(yamlData.error ?? "YAML export failed"); + return; + } + try { + const saved = await createWorkflow(channelId, yamlData.yaml); + setWorkflowId(saved.workflow.id); + const triggered = await triggerWorkflow(saved.workflow.id); + setActiveRunId(triggered.runId); + setStatusMessage(`Run started: ${triggered.runId.slice(0, 8)}…`); + void runsQuery.refetch(); + } catch (e) { + setStatusMessage(e instanceof Error ? e.message : "Run failed"); + } + }; + + const pendingApproval = approvalsQuery.data?.find( + (a) => a.status === "pending", + ); + + return ( +
+
+
+ +

Flow Studio

+
+
+ + +
+
+

+ Visual workflow builder (Buzz Hive). Drag blocks onto the canvas, save + as kind 46200, run via `buzz-workflow`. +

+ {statusMessage ? ( +

{statusMessage}

+ ) : null} + {pendingApproval ? ( +
+ +
+ ) : null} + +
+
+ + Block palette +
+ {loading ? ( +

Loading blocks…

+ ) : null} + {error ? ( +

+ Could not load blocks from relay: {error} +

+ ) : null} + {!loading && !error && blocks.length === 0 ? ( +

No blocks registered.

+ ) : null} + +
+ + + + + + +
+ ); +} diff --git a/desktop/src/features/flow-studio/ui/KnowledgeBasePanel.tsx b/desktop/src/features/flow-studio/ui/KnowledgeBasePanel.tsx new file mode 100644 index 00000000000..7523520eea4 --- /dev/null +++ b/desktop/src/features/flow-studio/ui/KnowledgeBasePanel.tsx @@ -0,0 +1,120 @@ +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { publishKbDocument } from "@/shared/api/tauriHiveStudio"; +import { Button } from "@/shared/ui/button"; + +const DEFAULT_KB_ID = "default"; + +export function KnowledgeBasePanel() { + const { activeCommunity } = useCommunities(); + const [filename, setFilename] = React.useState("notes.txt"); + const [content, setContent] = React.useState(""); + const [query, setQuery] = React.useState(""); + const [message, setMessage] = React.useState(null); + + const relayHttp = activeCommunity?.relayUrl?.replace(/^ws/i, "http"); + + const searchQuery = useQuery({ + enabled: Boolean(relayHttp && query.trim()), + queryFn: async () => { + const res = await fetch( + `${relayHttp}/flow-studio/knowledge/search?knowledge_base_id=${encodeURIComponent(DEFAULT_KB_ID)}&q=${encodeURIComponent(query)}&mode=semantic`, + ); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return (await res.json()) as { + hits?: Array<{ document_id: string; content: string }>; + }; + }, + queryKey: ["flow-kb-search", relayHttp, query], + }); + + const ingest = () => { + const documentId = `doc-${Date.now()}`; + void publishKbDocument({ + knowledgeBaseId: DEFAULT_KB_ID, + documentId, + filename, + mimeType: "text/plain", + content, + }) + .then((result) => setMessage(result.message)) + .catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : "Ingest failed"); + }); + }; + + return ( +
+

Knowledge base

+

+ Ingest documents as kind 46250; semantic search via pgvector (hash + embedding MVP). +

+ +