mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
feat(medulla): serve the workflow plane to the hosted orchestrator (#5266)
This commit is contained in:
@@ -464,6 +464,7 @@ jobs:
|
||||
openhuman/agent_registry/agents/loader.rs
|
||||
openhuman/composio/action_tool.rs
|
||||
openhuman/inference/local/mod.rs
|
||||
openhuman/socket/ops.rs
|
||||
openhuman/tool_registry/ops_tests.rs
|
||||
openhuman/tool_registry/schemas.rs
|
||||
openhuman/tools/ops_tests.rs
|
||||
|
||||
+6
-1
@@ -2574,6 +2574,7 @@ pub async fn bootstrap_core_runtime(
|
||||
pub async fn start_core_runtime_services(
|
||||
services: crate::core::runtime::ServiceSet,
|
||||
config: Option<&crate::openhuman::config::Config>,
|
||||
flows_enabled: bool,
|
||||
) {
|
||||
let Some(cfg) = config else {
|
||||
log::error!(
|
||||
@@ -2597,7 +2598,11 @@ pub async fn start_core_runtime_services(
|
||||
|
||||
match crate::openhuman::socket::global_socket_manager() {
|
||||
Some(socket_mgr) => {
|
||||
crate::core::runtime::services::spawn_socket_auto_connect(services, socket_mgr.clone());
|
||||
crate::core::runtime::services::spawn_socket_auto_connect(
|
||||
services,
|
||||
socket_mgr.clone(),
|
||||
flows_enabled,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
|
||||
@@ -702,7 +702,12 @@ impl CoreRuntime {
|
||||
/// each service keeps its own runtime config gate.
|
||||
async fn start_selected_services(&self) {
|
||||
use crate::core::runtime::services;
|
||||
jsonrpc::start_core_runtime_services(self.services, self.config.as_ref()).await;
|
||||
jsonrpc::start_core_runtime_services(
|
||||
self.services,
|
||||
self.config.as_ref(),
|
||||
self.ctx.domains().flows,
|
||||
)
|
||||
.await;
|
||||
|
||||
if self.services.heartbeat {
|
||||
services::spawn_login_gated_services(self.ctx.host_kind().is_desktop_shell());
|
||||
|
||||
@@ -416,19 +416,20 @@ fn spawn_mcp_reconnect_supervisor(config: Config) {
|
||||
pub fn spawn_socket_auto_connect(
|
||||
services: ServiceSet,
|
||||
socket_mgr: std::sync::Arc<crate::openhuman::socket::SocketManager>,
|
||||
_flows_enabled: bool,
|
||||
) {
|
||||
if services.socketio {
|
||||
tokio::spawn(async move {
|
||||
log::info!("[socket] Checking for stored session to auto-connect...");
|
||||
let config = match Config::load_or_init().await {
|
||||
Ok(c) => c,
|
||||
Ok(c) => std::sync::Arc::new(c),
|
||||
Err(e) => {
|
||||
log::debug!("[socket] Config not available for auto-connect: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let api_url = crate::api::config::effective_backend_api_url(&config.api_url);
|
||||
let token = match crate::api::jwt::get_session_token(&config) {
|
||||
let _initial_token = match crate::api::jwt::get_session_token(&config) {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => {
|
||||
log::info!(
|
||||
@@ -445,7 +446,22 @@ pub fn spawn_socket_auto_connect(
|
||||
"[socket] Session token found — auto-connecting to {}",
|
||||
api_url
|
||||
);
|
||||
if let Err(e) = socket_mgr.connect(&api_url, &token).await {
|
||||
// Keep the authenticated token and user-scoped workflow bridge in
|
||||
// one serialized identity transaction. The active profile may have
|
||||
// changed since CoreRuntime::build(), so the build-time Config is
|
||||
// not authoritative here.
|
||||
let _rebind = socket_mgr.lock_identity_rebind().await;
|
||||
if let Err(e) = socket_mgr.disconnect().await {
|
||||
log::error!("[socket] Auto-connect could not stop the prior connection: {e}");
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "flows")]
|
||||
if _flows_enabled {
|
||||
crate::openhuman::flows::medulla_bridge::install(std::sync::Arc::clone(&config));
|
||||
}
|
||||
let provider =
|
||||
crate::openhuman::socket::token_provider::token_provider_from_config(config);
|
||||
if let Err(e) = socket_mgr.connect_with_provider(&api_url, provider).await {
|
||||
log::error!("[socket] Auto-connect failed: {e}");
|
||||
} else {
|
||||
log::info!("[socket] Auto-connect initiated successfully");
|
||||
|
||||
@@ -702,6 +702,16 @@ pub async fn clear_session(config: &Config) -> Result<RpcOutcome<serde_json::Val
|
||||
.remove_profile(APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// The core process stays alive on logout. Tear down its authenticated
|
||||
// Socket.IO transport and the user-pinned workflow bridge so neither can
|
||||
// keep serving the signed-out account until a later reconnect.
|
||||
if let Some(manager) = crate::openhuman::socket::global_socket_manager() {
|
||||
if let Err(error) = manager.disconnect().await {
|
||||
tracing::warn!(%error, "failed to disconnect backend socket on logout");
|
||||
}
|
||||
}
|
||||
crate::openhuman::socket::medulla::workflows::clear_workflow_bridge();
|
||||
|
||||
// Clear the active user marker so subsequent config loads fall back to the
|
||||
// default (unauthenticated) openhuman directory.
|
||||
if let Ok(root_dir) = default_root_openhuman_dir() {
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
//! Backs the medulla harness protocol's workflow plane
|
||||
//! ([`crate::openhuman::socket::medulla::workflows::WorkflowBridge`]) with this
|
||||
//! host's own `flows::` store.
|
||||
//!
|
||||
//! The transport half is host-agnostic on purpose — it moves adverts, opaque
|
||||
//! JSON and one authoring turn, and parses no graph. This module is the
|
||||
//! openhuman half: it projects saved [`Flow`]s onto the wire's
|
||||
//! [`WorkflowDescriptor`], answers the three reads out of [`super::ops`], and
|
||||
//! runs the `workflow_builder` agent for a `copilot` turn.
|
||||
//!
|
||||
//! **Why the copilot persists here.** In the desktop product a builder turn is
|
||||
//! deliberately propose-only: [`super::ops::flows_build`] returns a candidate
|
||||
//! graph and the *user* accepts it in the copilot panel, then saves it on the
|
||||
//! canvas. On the medulla plane there is no panel and no user — a remote
|
||||
//! orchestrator asked for a change and is waiting on the answer — so the accept
|
||||
//! is performed here, by the host, from the proposal the turn produced. Two
|
||||
//! guards keep that safe rather than merely convenient:
|
||||
//!
|
||||
//! - a **create** is persisted with `require_approval: true` unconditionally, so
|
||||
//! every outbound action of a remotely-authored workflow still parks for a
|
||||
//! human decision at run time;
|
||||
//! - an **update** passes `require_approval: None`, so a remote turn can raise
|
||||
//! a workflow's approval requirement (via `flows_update`'s own side-effect
|
||||
//! enforcement) but can never lower one the user set.
|
||||
//!
|
||||
//! `flows_create` additionally saves any automatic-trigger graph **disabled**,
|
||||
//! so nothing authored over this plane can start firing on its own.
|
||||
//!
|
||||
//! **Why the outcome is diffed, not reported.** [`WorkflowBridge::copilot`]'s
|
||||
//! `changes` are computed by [`diff_workflow`] over the workflow this turn
|
||||
//! successfully persisted, using listings taken before and after the turn —
|
||||
//! never from what the agent said it did. Scoping the diff matters because a
|
||||
//! copilot turn can run for minutes while a user edits unrelated workflows.
|
||||
//! That is the whole reason medulla-v1 forwards the outcome verbatim instead of
|
||||
//! re-deriving it: the claim that reaches the orchestrator is a statement about
|
||||
//! the store, made by the store's owner.
|
||||
//!
|
||||
//! **Threading.** The three reads are synchronous on the trait but every
|
||||
//! `flows::` op is `async`, so they bridge with a `Handle::block_on` — legal
|
||||
//! because the transport only ever calls them from `spawn_blocking` (see
|
||||
//! [`super::super::socket::medulla::workflows`]), never on a runtime worker.
|
||||
//! `copilot` is `async` end to end and needs no hop.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use tinyflows::model::WorkflowGraph;
|
||||
|
||||
use super::agents::workflow_builder::builder_prompt::{BuildMode, BuilderRequest};
|
||||
use super::node_contracts::{all_node_kind_contracts, node_kind_contract, NODE_KINDS};
|
||||
use super::ops;
|
||||
use super::types::Flow;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::socket::medulla::payloads::{CopilotOutcome, WorkflowDescriptor};
|
||||
use crate::openhuman::socket::medulla::workflows::{set_workflow_bridge, WorkflowBridge};
|
||||
|
||||
/// How many runs a `runs` read returns. The orchestrator wants "did this
|
||||
/// workflow work lately", not an audit log, and the server caps a result at
|
||||
/// 1 MiB — a bounded window answers the question and can never hit that cap.
|
||||
const RUN_HISTORY_LIMIT: usize = 20;
|
||||
|
||||
/// Cap on a generated advert description. It is prompt surface rendered for
|
||||
/// every workflow on every review pass, so a 40-node graph must not crowd out
|
||||
/// the rest of the section.
|
||||
const MAX_DESCRIPTION_CHARS: usize = 400;
|
||||
|
||||
/// Persistence tools hidden from a medulla copilot turn.
|
||||
///
|
||||
/// The generic builder exposes these for its product surfaces, but this bridge
|
||||
/// deliberately owns persistence in [`apply_proposal`] so it can enforce the
|
||||
/// observed version and approval floor exactly once.
|
||||
const MEDULLA_COPILOT_HIDDEN_TOOLS: &[&str] = &[
|
||||
"save_workflow",
|
||||
"create_workflow",
|
||||
"duplicate_flow",
|
||||
"edit_workflow",
|
||||
];
|
||||
|
||||
/// Install the flows-backed bridge on the medulla workflow plane.
|
||||
///
|
||||
/// Called once at boot, gated on the `flows` domain being live. Installing also
|
||||
/// advertises immediately, so a core that connected before this ran still
|
||||
/// publishes its workflows.
|
||||
pub fn install(config: Arc<Config>) {
|
||||
log::debug!("[flows] installing the medulla workflow bridge over the flows store");
|
||||
set_workflow_bridge(Arc::new(FlowsWorkflowBridge::pinned(config)));
|
||||
}
|
||||
|
||||
/// The `flows::`-backed [`WorkflowBridge`].
|
||||
///
|
||||
/// Holds no store handle: `flows::` is addressed by [`Config`], and the RPC
|
||||
/// surface already re-reads the config per call rather than caching a snapshot
|
||||
/// that a workspace switch would invalidate. `config` is `Some` only when a
|
||||
/// caller pinned one (tests, and embedders that already hold a config).
|
||||
pub struct FlowsWorkflowBridge {
|
||||
config: Option<Arc<Config>>,
|
||||
}
|
||||
|
||||
impl FlowsWorkflowBridge {
|
||||
/// A bridge that resolves the process's current config on every call.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self { config: None }
|
||||
}
|
||||
|
||||
/// A bridge pinned to one config, for callers that already hold one and for
|
||||
/// tests, which must address a temp workspace rather than the real one.
|
||||
#[must_use]
|
||||
pub fn pinned(config: Arc<Config>) -> Self {
|
||||
Self {
|
||||
config: Some(config),
|
||||
}
|
||||
}
|
||||
|
||||
/// The config to address the store with, loaded if not pinned.
|
||||
async fn config(&self) -> Result<Arc<Config>, String> {
|
||||
match &self.config {
|
||||
Some(config) => Ok(Arc::clone(config)),
|
||||
None => crate::openhuman::config::rpc::load_config_with_timeout()
|
||||
.await
|
||||
.map(Arc::new)
|
||||
.map_err(|err| format!("this agent could not read its config: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one `flows::` op to completion from a synchronous trait method.
|
||||
///
|
||||
/// Only legal off the runtime's async threads. The transport guarantees that
|
||||
/// — every sync bridge call is already inside `spawn_blocking` — and a
|
||||
/// caller that violates it panics there rather than deadlocking, which that
|
||||
/// same `spawn_blocking` converts into a reported error.
|
||||
fn on_store<T, F>(&self, run: impl FnOnce(Arc<Config>) -> F) -> Result<T, String>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, String>>,
|
||||
{
|
||||
let handle = tokio::runtime::Handle::try_current()
|
||||
.map_err(|_| "this agent's workflow store has no runtime to answer on".to_string())?;
|
||||
handle.block_on(async move {
|
||||
let config = self.config().await?;
|
||||
run(config).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FlowsWorkflowBridge {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkflowBridge for FlowsWorkflowBridge {
|
||||
fn action_dir(&self) -> Option<String> {
|
||||
self.config
|
||||
.as_ref()
|
||||
.map(|config| config.action_dir.display().to_string())
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<WorkflowDescriptor> {
|
||||
match self.try_list() {
|
||||
Ok(descriptors) => descriptors,
|
||||
Err(err) => {
|
||||
log::warn!("[flows] medulla bridge could not list workflows: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_list(&self) -> Result<Vec<WorkflowDescriptor>, String> {
|
||||
self.on_store(|config| async move {
|
||||
ops::flows_list(&config)
|
||||
.await
|
||||
.map(|outcome| outcome.value)
|
||||
.map(|flows| flows.iter().map(describe_flow).collect::<Vec<_>>())
|
||||
})
|
||||
}
|
||||
|
||||
fn get(&self, id: &str) -> Result<Value, String> {
|
||||
let id = id.to_string();
|
||||
self.on_store(|config| async move {
|
||||
let flow = ops::flows_get(&config, &id).await?.value;
|
||||
serde_json::to_value(&flow)
|
||||
.map_err(|err| format!("failed to render workflow '{id}': {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn node_kinds(&self, kind: Option<&str>) -> Result<Value, String> {
|
||||
// Purely static host+engine metadata — no store, no config, no runtime.
|
||||
match kind.map(str::trim).filter(|kind| !kind.is_empty()) {
|
||||
Some(kind) => {
|
||||
let contract = node_kind_contract(kind).ok_or_else(|| {
|
||||
format!(
|
||||
"'{kind}' is not a node kind here; the kinds are: {}",
|
||||
NODE_KINDS.join(", ")
|
||||
)
|
||||
})?;
|
||||
Ok(json!({ "kinds": [node_kind_json(&contract)] }))
|
||||
}
|
||||
None => Ok(json!({
|
||||
"kinds": all_node_kind_contracts()
|
||||
.iter()
|
||||
.map(node_kind_json)
|
||||
.collect::<Vec<_>>(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn runs(&self, id: &str) -> Result<Value, String> {
|
||||
let id = id.to_string();
|
||||
self.on_store(|config| async move {
|
||||
let runs = ops::flows_list_runs(&config, &id, RUN_HISTORY_LIMIT)
|
||||
.await?
|
||||
.value;
|
||||
Ok(json!({ "runs": runs.iter().map(run_json).collect::<Vec<_>>() }))
|
||||
})
|
||||
}
|
||||
|
||||
async fn copilot(
|
||||
&self,
|
||||
instruction: &str,
|
||||
workflow_id: Option<&str>,
|
||||
) -> Result<CopilotOutcome, String> {
|
||||
let config = self.config().await?;
|
||||
let before = ops::flows_list(&config).await?.value;
|
||||
let request = builder_request(instruction, workflow_id, &before)?;
|
||||
let expected_version = workflow_id.and_then(|id| {
|
||||
before
|
||||
.iter()
|
||||
.find(|flow| flow.id == id)
|
||||
.map(|flow| flow.updated_at.as_str())
|
||||
});
|
||||
|
||||
// Headless: no chat thread to stream into, so the turn runs under the
|
||||
// CLI origin with the full run-advancing hide-list — a remote authoring
|
||||
// turn proposes a graph, it never runs one.
|
||||
let built = ops::flows_build_with_extra_hidden_tools(
|
||||
&config,
|
||||
request,
|
||||
None,
|
||||
MEDULLA_COPILOT_HIDDEN_TOOLS,
|
||||
)
|
||||
.await?
|
||||
.value;
|
||||
let mut reply = built
|
||||
.get("assistant_text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let mut applied = None;
|
||||
if let Some(proposal) = built.get("proposal").filter(|value| !value.is_null()) {
|
||||
match apply_proposal(&config, workflow_id, proposal, expected_version).await {
|
||||
Ok(saved) => applied = Some(saved),
|
||||
// The turn happened and its reply is worth returning; only the
|
||||
// save failed. Say so in the reply rather than discarding the
|
||||
// whole outcome — and let the diff below report (truthfully)
|
||||
// that nothing changed.
|
||||
Err(err) => {
|
||||
log::warn!("[flows] medulla copilot could not persist its proposal: {err}");
|
||||
reply = format!("{reply}\n\nThe proposed graph could not be saved: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let changes = applied
|
||||
.as_ref()
|
||||
.map(|saved| diff_workflow(&before, std::slice::from_ref(&saved.flow), &saved.flow.id))
|
||||
.unwrap_or_default();
|
||||
let created = applied
|
||||
.filter(|saved| saved.created)
|
||||
.map(|saved| saved.flow.id);
|
||||
Ok(CopilotOutcome {
|
||||
reply,
|
||||
changes,
|
||||
created,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Project one saved flow onto the advert the orchestrator reasons over.
|
||||
///
|
||||
/// `name`/`description` are left as plain `String`s: the wire type skips them
|
||||
/// when empty, so a nameless flow omits the key rather than sending `""`.
|
||||
fn describe_flow(flow: &Flow) -> WorkflowDescriptor {
|
||||
WorkflowDescriptor {
|
||||
id: flow.id.clone(),
|
||||
name: flow.name.trim().to_string(),
|
||||
description: describe_graph(&flow.graph),
|
||||
node_count: u32::try_from(flow.graph.nodes.len()).unwrap_or(u32::MAX),
|
||||
enabled: Some(flow.enabled),
|
||||
trigger_kind: trigger_kind(&flow.graph),
|
||||
// Provenance is the backend's to stamp: this host runs one identity, so
|
||||
// the batch-level `agentId` on the registration frame is the whole
|
||||
// answer and a per-advert claim would only be redundant.
|
||||
agent_id: None,
|
||||
workspace_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The open-vocabulary trigger label, when the graph has exactly one trigger
|
||||
/// that names its kind. `None` covers "no trigger", "several triggers", and a
|
||||
/// trigger with no `trigger_kind` — all cases where a claim would be a guess.
|
||||
fn trigger_kind(graph: &WorkflowGraph) -> Option<String> {
|
||||
graph
|
||||
.trigger()?
|
||||
.config
|
||||
.get("trigger_kind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// A one-line prose description of what a graph does, for the advert.
|
||||
///
|
||||
/// A flow has no author-written description field, so it is generated from the
|
||||
/// graph's own step names: `"<trigger> → step → step"`. That is the same
|
||||
/// information the Workflows list renders, written for a prompt instead of a
|
||||
/// row, and truncated so one large graph cannot dominate the section.
|
||||
fn describe_graph(graph: &WorkflowGraph) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push(match trigger_kind(graph) {
|
||||
Some(kind) => format!("on {kind}"),
|
||||
None => "no trigger".to_string(),
|
||||
});
|
||||
parts.extend(
|
||||
graph
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| node.kind != tinyflows::model::NodeKind::Trigger)
|
||||
.map(|node| node.name.trim())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string),
|
||||
);
|
||||
|
||||
let rendered = parts.join(" → ");
|
||||
if rendered.chars().count() <= MAX_DESCRIPTION_CHARS {
|
||||
return rendered;
|
||||
}
|
||||
let truncated: String = rendered.chars().take(MAX_DESCRIPTION_CHARS).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
/// Project a node-kind contract onto the `WorkflowNodeKind` shape medulla-v1's
|
||||
/// port declares — camelCase, because the backend spreads this object straight
|
||||
/// onto the port type and a snake_case key would land beside the field it was
|
||||
/// meant to be rather than in it.
|
||||
fn node_kind_json(contract: &crate::openhuman::flows::node_contracts::NodeKindContract) -> Value {
|
||||
json!({
|
||||
"kind": contract.kind,
|
||||
"summary": contract.summary,
|
||||
"description": contract.description,
|
||||
"configFields": contract.config_fields,
|
||||
"ports": contract.ports,
|
||||
"example": contract.example,
|
||||
"notes": contract.notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Project a run row onto the `WorkflowRunSummary` shape the port declares.
|
||||
///
|
||||
/// Timestamps cross as epoch milliseconds because that is what the port's
|
||||
/// `startedAt`/`finishedAt` are; the store keeps RFC3339, and an unparseable
|
||||
/// stamp is omitted rather than sent as `0` (which reads as 1970, not "unknown").
|
||||
/// The per-node `steps` are dropped: they are the run *inspector's* payload, and
|
||||
/// a delegated-workflow read only asks whether it worked.
|
||||
fn run_json(run: &super::types::FlowRun) -> Value {
|
||||
let mut value = json!({
|
||||
"id": run.id,
|
||||
"workflowId": run.flow_id,
|
||||
"status": run.status,
|
||||
});
|
||||
if let Some(started) = epoch_millis(&run.started_at) {
|
||||
value["startedAt"] = json!(started);
|
||||
}
|
||||
if let Some(finished) = run.finished_at.as_deref().and_then(epoch_millis) {
|
||||
value["finishedAt"] = json!(finished);
|
||||
}
|
||||
if let Some(error) = run.error.as_deref().filter(|error| !error.is_empty()) {
|
||||
value["error"] = json!(error);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// RFC3339 → epoch milliseconds, or `None` for a stamp this build cannot read.
|
||||
fn epoch_millis(stamp: &str) -> Option<i64> {
|
||||
chrono::DateTime::parse_from_rfc3339(stamp)
|
||||
.ok()
|
||||
.map(|parsed| parsed.timestamp_millis())
|
||||
}
|
||||
|
||||
/// Build the authoring-turn request for one copilot call.
|
||||
///
|
||||
/// Naming a workflow is a **revise** of that exact graph, so the turn is briefed
|
||||
/// with the saved graph and its id; naming none is a **create**. An id the store
|
||||
/// does not hold fails here rather than silently degrading into a create — the
|
||||
/// orchestrator asked to change something specific, and creating a second
|
||||
/// workflow instead would be the wrong kind of surprising.
|
||||
fn builder_request(
|
||||
instruction: &str,
|
||||
workflow_id: Option<&str>,
|
||||
flows: &[Flow],
|
||||
) -> Result<BuilderRequest, String> {
|
||||
let (mode, graph, flow_id) = match workflow_id {
|
||||
Some(id) => {
|
||||
let flow = flows
|
||||
.iter()
|
||||
.find(|flow| flow.id == id)
|
||||
.ok_or_else(|| format!("workflow '{id}' is not in this agent's store"))?;
|
||||
let graph = serde_json::to_value(&flow.graph)
|
||||
.map_err(|err| format!("failed to read workflow '{id}': {err}"))?;
|
||||
(BuildMode::Revise, Some(graph), Some(id.to_string()))
|
||||
}
|
||||
None => (BuildMode::Create, None, None),
|
||||
};
|
||||
Ok(BuilderRequest {
|
||||
mode,
|
||||
instruction: instruction.to_string(),
|
||||
graph,
|
||||
flow_id,
|
||||
run_id: None,
|
||||
error: None,
|
||||
failing_node_ids: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The exact row committed by [`apply_proposal`].
|
||||
#[derive(Debug)]
|
||||
struct AppliedProposal {
|
||||
flow: Flow,
|
||||
created: bool,
|
||||
}
|
||||
|
||||
/// Persist the graph a builder turn proposed, returning the committed row.
|
||||
///
|
||||
/// See the module docs for why this host performs the accept the copilot panel
|
||||
/// would otherwise perform, and for the two approval guards that bound it.
|
||||
async fn apply_proposal(
|
||||
config: &Config,
|
||||
workflow_id: Option<&str>,
|
||||
proposal: &Value,
|
||||
expected_version: Option<&str>,
|
||||
) -> Result<AppliedProposal, String> {
|
||||
let graph = proposal
|
||||
.get("graph")
|
||||
.filter(|graph| !graph.is_null())
|
||||
.ok_or_else(|| "the copilot proposed no graph".to_string())?
|
||||
.clone();
|
||||
|
||||
match workflow_id {
|
||||
// `require_approval: None` keeps whatever the user set. A remote turn
|
||||
// may still have it forced ON by `flows_update`'s side-effect check; it
|
||||
// can never turn it off.
|
||||
Some(id) => {
|
||||
let name = proposal
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string);
|
||||
let flow = ops::flows_update_disarming_automatic(
|
||||
config,
|
||||
id,
|
||||
name,
|
||||
Some(graph),
|
||||
None,
|
||||
expected_version.map(str::to_string),
|
||||
)
|
||||
.await?
|
||||
.value;
|
||||
Ok(AppliedProposal {
|
||||
flow,
|
||||
created: false,
|
||||
})
|
||||
}
|
||||
None => {
|
||||
let name = proposal
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("Untitled workflow")
|
||||
.to_string();
|
||||
// `true`, not the proposal's own value: nothing authored by a remote
|
||||
// instruction acts outward without a human decision at run time.
|
||||
let flow = ops::flows_create(config, name, graph, true).await?.value;
|
||||
Ok(AppliedProposal {
|
||||
flow,
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What actually changed in the store across a copilot turn, as one line per
|
||||
/// change.
|
||||
///
|
||||
/// Derived from two listings, never from the agent's account of its own turn —
|
||||
/// see the module docs. A turn that talked and saved nothing yields an empty
|
||||
/// list, which is exactly the claim it earns.
|
||||
fn diff_flows(before: &[Flow], after: &[Flow]) -> Vec<String> {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
for flow in after {
|
||||
let Some(previous) = before.iter().find(|candidate| candidate.id == flow.id) else {
|
||||
changes.push(format!(
|
||||
"created workflow \"{}\" ({}) with {} node(s)",
|
||||
flow.name,
|
||||
flow.id,
|
||||
flow.graph.nodes.len()
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if previous.name != flow.name {
|
||||
changes.push(format!(
|
||||
"renamed workflow {} from \"{}\" to \"{}\"",
|
||||
flow.id, previous.name, flow.name
|
||||
));
|
||||
}
|
||||
if previous.graph != flow.graph {
|
||||
changes.push(format!(
|
||||
"rewrote the graph of workflow \"{}\" ({}): {} node(s) → {} node(s)",
|
||||
flow.name,
|
||||
flow.id,
|
||||
previous.graph.nodes.len(),
|
||||
flow.graph.nodes.len()
|
||||
));
|
||||
}
|
||||
if previous.enabled != flow.enabled {
|
||||
let state = if flow.enabled { "enabled" } else { "disabled" };
|
||||
changes.push(format!("{state} workflow \"{}\" ({})", flow.name, flow.id));
|
||||
}
|
||||
if previous.require_approval != flow.require_approval {
|
||||
let state = if flow.require_approval {
|
||||
"now requires"
|
||||
} else {
|
||||
"no longer requires"
|
||||
};
|
||||
changes.push(format!(
|
||||
"workflow \"{}\" ({}) {state} approval for outbound actions",
|
||||
flow.name, flow.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for flow in before {
|
||||
if !after.iter().any(|candidate| candidate.id == flow.id) {
|
||||
changes.push(format!("deleted workflow \"{}\" ({})", flow.name, flow.id));
|
||||
}
|
||||
}
|
||||
|
||||
changes
|
||||
}
|
||||
|
||||
/// Diff only the workflow this copilot turn successfully persisted.
|
||||
///
|
||||
/// A turn may overlap arbitrary user edits elsewhere in the store. Those edits
|
||||
/// are real, but they are not attributable to this turn and must not be claimed
|
||||
/// in its result.
|
||||
fn diff_workflow(before: &[Flow], after: &[Flow], workflow_id: &str) -> Vec<String> {
|
||||
let before: Vec<Flow> = before
|
||||
.iter()
|
||||
.filter(|flow| flow.id == workflow_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
let after: Vec<Flow> = after
|
||||
.iter()
|
||||
.filter(|flow| flow.id == workflow_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
diff_flows(&before, &after)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "medulla_bridge_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,593 @@
|
||||
use super::*;
|
||||
use crate::openhuman::config::Config;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tinyflows::model::{Node, NodeKind};
|
||||
|
||||
/// A config pointed at a throwaway workspace, so every test addresses its own
|
||||
/// SQLite store instead of the developer's real one.
|
||||
fn test_config(tmp: &TempDir) -> Arc<Config> {
|
||||
let config = Config {
|
||||
workspace_dir: tmp.path().join("workspace"),
|
||||
action_dir: tmp.path().join("workspace"),
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
Arc::new(config)
|
||||
}
|
||||
|
||||
fn node(id: &str, kind: NodeKind, name: &str, config: Value) -> Node {
|
||||
Node {
|
||||
id: id.to_string(),
|
||||
kind,
|
||||
type_version: 1,
|
||||
name: name.to_string(),
|
||||
config,
|
||||
ports: Vec::new(),
|
||||
position: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal manual-trigger graph with one named step.
|
||||
fn graph_with_step(step_name: &str) -> WorkflowGraph {
|
||||
WorkflowGraph {
|
||||
nodes: vec![
|
||||
node(
|
||||
"t",
|
||||
NodeKind::Trigger,
|
||||
"Trigger",
|
||||
json!({ "trigger_kind": "manual" }),
|
||||
),
|
||||
node(
|
||||
"s",
|
||||
NodeKind::Code,
|
||||
step_name,
|
||||
json!({ "code": "return {}" }),
|
||||
),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn flow(id: &str, name: &str, graph: WorkflowGraph) -> Flow {
|
||||
Flow {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
enabled: true,
|
||||
graph,
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
last_run_at: None,
|
||||
last_status: None,
|
||||
require_approval: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── advert projection ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn describe_flow_counts_every_node_and_names_the_trigger() {
|
||||
let descriptor = describe_flow(&flow("f1", "Deploy", graph_with_step("Ship it")));
|
||||
assert_eq!(descriptor.id, "f1");
|
||||
assert_eq!(descriptor.name, "Deploy");
|
||||
// Trigger + step: the whole graph, not just the executable half.
|
||||
assert_eq!(descriptor.node_count, 2);
|
||||
assert_eq!(descriptor.enabled, Some(true));
|
||||
assert_eq!(descriptor.trigger_kind.as_deref(), Some("manual"));
|
||||
assert_eq!(descriptor.description, "on manual → Ship it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_name_stays_absent_on_the_wire_rather_than_empty() {
|
||||
let descriptor = describe_flow(&flow("f1", " ", graph_with_step("Step")));
|
||||
assert!(descriptor.name.is_empty());
|
||||
let wire = serde_json::to_value(&descriptor).unwrap();
|
||||
// The contract is an ABSENT key, not `""` — both sides treat it as optional.
|
||||
assert!(wire.get("name").is_none(), "wire = {wire}");
|
||||
assert_eq!(wire["nodeCount"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_triggerless_graph_claims_no_trigger_kind() {
|
||||
let graph = WorkflowGraph {
|
||||
nodes: vec![node("s", NodeKind::Code, "Step", json!({}))],
|
||||
..Default::default()
|
||||
};
|
||||
let descriptor = describe_flow(&flow("f1", "Orphan", graph));
|
||||
assert_eq!(descriptor.trigger_kind, None);
|
||||
assert_eq!(descriptor.description, "no trigger → Step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_graph_description_is_truncated() {
|
||||
let nodes = (0..80)
|
||||
.map(|i| {
|
||||
node(
|
||||
&format!("s{i}"),
|
||||
NodeKind::Code,
|
||||
"A fairly long step name",
|
||||
json!({}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let descriptor = describe_flow(&flow(
|
||||
"f1",
|
||||
"Big",
|
||||
WorkflowGraph {
|
||||
nodes,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
descriptor.description.chars().count(),
|
||||
MAX_DESCRIPTION_CHARS + 1
|
||||
);
|
||||
assert!(descriptor.description.ends_with('…'));
|
||||
}
|
||||
|
||||
// ── node kinds ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn node_kinds_renders_the_catalog_in_the_ports_camel_case_shape() {
|
||||
let bridge = FlowsWorkflowBridge::new();
|
||||
let all = bridge.node_kinds(None).unwrap();
|
||||
let kinds = all["kinds"].as_array().unwrap();
|
||||
assert_eq!(kinds.len(), NODE_KINDS.len());
|
||||
// `configFields`, not `config_fields`: the backend spreads this object onto
|
||||
// the port type, so a snake_case key would miss the field entirely.
|
||||
assert!(kinds[0].get("configFields").is_some());
|
||||
assert!(kinds[0].get("config_fields").is_none());
|
||||
|
||||
let one = bridge.node_kinds(Some("agent")).unwrap();
|
||||
assert_eq!(one["kinds"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(one["kinds"][0]["kind"], "agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_node_kind_reports_the_real_ones() {
|
||||
let err = FlowsWorkflowBridge::new()
|
||||
.node_kinds(Some("teleport"))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("teleport"), "{err}");
|
||||
assert!(err.contains("tool_call"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_kind_filter_means_the_whole_catalog() {
|
||||
let bridge = FlowsWorkflowBridge::new();
|
||||
assert_eq!(
|
||||
bridge.node_kinds(Some(" ")).unwrap(),
|
||||
bridge.node_kinds(None).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// ── run projection ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn run_json_emits_epoch_millis_and_omits_what_it_cannot_read() {
|
||||
let run = super::super::types::FlowRun {
|
||||
id: "r1".to_string(),
|
||||
flow_id: "f1".to_string(),
|
||||
thread_id: "r1".to_string(),
|
||||
status: "completed".to_string(),
|
||||
started_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
finished_at: Some("not a timestamp".to_string()),
|
||||
steps: Vec::new(),
|
||||
pending_approvals: Vec::new(),
|
||||
error: None,
|
||||
};
|
||||
let value = run_json(&run);
|
||||
assert_eq!(value["id"], "r1");
|
||||
assert_eq!(value["workflowId"], "f1");
|
||||
assert_eq!(value["startedAt"], 1_767_225_600_000_i64);
|
||||
// An unreadable stamp is absent, never `0` — that would read as 1970.
|
||||
assert!(value.get("finishedAt").is_none(), "{value}");
|
||||
assert!(value.get("error").is_none(), "{value}");
|
||||
// The step-by-step payload belongs to the run inspector, not to this read.
|
||||
assert!(value.get("steps").is_none(), "{value}");
|
||||
}
|
||||
|
||||
// ── the store reads, over a real SQLite store ────────────────────────────────
|
||||
|
||||
/// Drive a synchronous bridge read the way the transport does — from a blocking
|
||||
/// thread — since that is the only context its `block_on` is legal in.
|
||||
async fn off_runtime<T, F>(read: F) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> T + Send + 'static,
|
||||
{
|
||||
tokio::task::spawn_blocking(read).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_and_get_answer_out_of_the_real_store() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let created = ops::flows_create(
|
||||
&config,
|
||||
"Deploy".to_string(),
|
||||
serde_json::to_value(graph_with_step("Ship it")).unwrap(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
|
||||
let bridge = Arc::new(FlowsWorkflowBridge::pinned(Arc::clone(&config)));
|
||||
|
||||
let listed = off_runtime({
|
||||
let bridge = Arc::clone(&bridge);
|
||||
move || bridge.list()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, created.id);
|
||||
assert_eq!(listed[0].node_count, 2);
|
||||
|
||||
let id = created.id.clone();
|
||||
let detail = off_runtime({
|
||||
let bridge = Arc::clone(&bridge);
|
||||
move || bridge.get(&id)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// The graph crosses whole and opaque — nothing above this parses it.
|
||||
assert_eq!(detail["id"], created.id);
|
||||
assert!(detail["graph"]["nodes"].as_array().unwrap().len() == 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_id_is_a_readable_error_not_an_empty_answer() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let bridge = Arc::new(FlowsWorkflowBridge::pinned(test_config(&tmp)));
|
||||
let err = off_runtime(move || bridge.get("nope")).await.unwrap_err();
|
||||
assert!(err.contains("nope"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_answers_with_an_empty_window_for_a_flow_that_never_ran() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let created = ops::flows_create(
|
||||
&config,
|
||||
"Deploy".to_string(),
|
||||
serde_json::to_value(graph_with_step("Ship it")).unwrap(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
let bridge = Arc::new(FlowsWorkflowBridge::pinned(config));
|
||||
let runs = off_runtime(move || bridge.runs(&created.id)).await.unwrap();
|
||||
assert_eq!(runs["runs"], json!([]));
|
||||
}
|
||||
|
||||
// ── persisting a proposal ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_created_workflow_always_requires_approval() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let proposal = json!({
|
||||
"name": "Remote build",
|
||||
"graph": serde_json::to_value(graph_with_step("Ship it")).unwrap(),
|
||||
// The proposal's own answer is deliberately ignored on this path.
|
||||
"require_approval": false,
|
||||
});
|
||||
|
||||
let applied = apply_proposal(&config, None, &proposal, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(applied.created);
|
||||
let saved = ops::flows_get(&config, &applied.flow.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
assert_eq!(saved.name, "Remote build");
|
||||
assert!(
|
||||
saved.require_approval,
|
||||
"a remotely authored workflow must still park its outbound actions"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_update_cannot_lower_the_approval_requirement() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let created = ops::flows_create(
|
||||
&config,
|
||||
"Deploy".to_string(),
|
||||
serde_json::to_value(graph_with_step("Ship it")).unwrap(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
|
||||
let proposal = json!({
|
||||
"name": "Deploy v2",
|
||||
"graph": serde_json::to_value(graph_with_step("Ship it twice")).unwrap(),
|
||||
"require_approval": false,
|
||||
});
|
||||
let applied = apply_proposal(
|
||||
&config,
|
||||
Some(&created.id),
|
||||
&proposal,
|
||||
Some(&created.updated_at),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!applied.created, "an update is not a creation");
|
||||
assert_eq!(applied.flow.id, created.id);
|
||||
|
||||
let saved = ops::flows_get(&config, &created.id).await.unwrap().value;
|
||||
assert_eq!(saved.name, "Deploy v2");
|
||||
assert!(
|
||||
saved.require_approval,
|
||||
"the user's approval requirement survives a remote turn"
|
||||
);
|
||||
}
|
||||
|
||||
fn schedule_graph(cron: &str) -> Value {
|
||||
json!({
|
||||
"name": "scheduled",
|
||||
"nodes": [{
|
||||
"id": "t",
|
||||
"kind": "trigger",
|
||||
"name": "Trigger",
|
||||
"config": { "trigger_kind": "schedule", "schedule": cron }
|
||||
}],
|
||||
"edges": []
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_remote_automatic_revision_requires_explicit_rearming() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let created = ops::flows_create(
|
||||
&config,
|
||||
"Scheduled".to_string(),
|
||||
schedule_graph("0 9 * * *"),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
assert!(!created.enabled, "automatic flows are born disarmed");
|
||||
|
||||
let armed = ops::flows_set_enabled(&config, &created.id, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
assert!(armed.enabled, "precondition: the user explicitly armed it");
|
||||
|
||||
let proposal = json!({
|
||||
"name": "Scheduled v2",
|
||||
"graph": schedule_graph("0 10 * * *"),
|
||||
});
|
||||
let applied = apply_proposal(&config, Some(&armed.id), &proposal, Some(&armed.updated_at))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!applied.flow.enabled);
|
||||
let saved = ops::flows_get(&config, &armed.id).await.unwrap().value;
|
||||
assert!(!saved.enabled, "the revised schedule must stay disarmed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_proposal_without_a_graph_is_refused() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let err = apply_proposal(&config, None, &json!({ "name": "Nothing" }), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no graph"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_update_refuses_to_overwrite_a_concurrent_edit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let created = ops::flows_create(
|
||||
&config,
|
||||
"Deploy".to_string(),
|
||||
serde_json::to_value(graph_with_step("Ship it")).unwrap(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
|
||||
ops::flows_update(
|
||||
&config,
|
||||
&created.id,
|
||||
Some("User edit".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some(created.updated_at.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let proposal = json!({
|
||||
"name": "Copilot edit",
|
||||
"graph": serde_json::to_value(graph_with_step("Ship it twice")).unwrap(),
|
||||
});
|
||||
let err = apply_proposal(
|
||||
&config,
|
||||
Some(&created.id),
|
||||
&proposal,
|
||||
Some(&created.updated_at),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("conflict"), "{err}");
|
||||
|
||||
let saved = ops::flows_get(&config, &created.id).await.unwrap().value;
|
||||
assert_eq!(saved.name, "User edit");
|
||||
}
|
||||
|
||||
// ── the accountability diff ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn diff_reports_a_creation_with_its_size() {
|
||||
let after = vec![flow("f1", "Deploy", graph_with_step("Ship it"))];
|
||||
let changes = diff_flows(&[], &after);
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert!(
|
||||
changes[0].contains("created workflow \"Deploy\" (f1)"),
|
||||
"{changes:?}"
|
||||
);
|
||||
assert!(changes[0].contains("2 node(s)"), "{changes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_reports_a_rewrite_a_rename_and_a_deletion() {
|
||||
let before = vec![
|
||||
flow("f1", "Deploy", graph_with_step("Ship it")),
|
||||
flow("f2", "Doomed", graph_with_step("Step")),
|
||||
];
|
||||
let mut renamed = flow("f1", "Deploy v2", graph_with_step("Ship it faster"));
|
||||
renamed.enabled = false;
|
||||
let changes = diff_flows(&before, &[renamed]);
|
||||
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|line| line.contains("renamed workflow f1")),
|
||||
"{changes:?}"
|
||||
);
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|line| line.contains("rewrote the graph")),
|
||||
"{changes:?}"
|
||||
);
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|line| line.starts_with("disabled workflow")),
|
||||
"{changes:?}"
|
||||
);
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|line| line.contains("deleted workflow \"Doomed\" (f2)")),
|
||||
"{changes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_that_changed_nothing_claims_nothing() {
|
||||
let flows = vec![flow("f1", "Deploy", graph_with_step("Ship it"))];
|
||||
// The whole point: the agent's account of its turn cannot inflate this.
|
||||
assert!(diff_flows(&flows, &flows).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_reports_an_approval_requirement_change() {
|
||||
let before = vec![flow("f1", "Deploy", graph_with_step("Ship it"))];
|
||||
let mut after = before.clone();
|
||||
after[0].require_approval = true;
|
||||
let changes = diff_flows(&before, &after);
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert!(changes[0].contains("now requires approval"), "{changes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_diff_excludes_concurrent_changes_to_other_workflows() {
|
||||
let before = vec![
|
||||
flow("target", "Deploy", graph_with_step("Ship it")),
|
||||
flow("other", "Unrelated", graph_with_step("Before")),
|
||||
];
|
||||
let after = vec![
|
||||
flow("target", "Deploy v2", graph_with_step("Ship it twice")),
|
||||
flow("other", "User edit", graph_with_step("After")),
|
||||
flow("new", "Also user-created", graph_with_step("Step")),
|
||||
];
|
||||
|
||||
let changes = diff_workflow(&before, &after, "target");
|
||||
assert!(
|
||||
changes.iter().all(|line| !line.contains("other")
|
||||
&& !line.contains("Unrelated")
|
||||
&& !line.contains("User edit")
|
||||
&& !line.contains("new")
|
||||
&& !line.contains("Also user-created")),
|
||||
"{changes:?}"
|
||||
);
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|line| line.contains("renamed workflow target")),
|
||||
"{changes:?}"
|
||||
);
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|line| line.contains("rewrote the graph")),
|
||||
"{changes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medulla_copilot_hides_every_persistence_tool() {
|
||||
assert_eq!(
|
||||
MEDULLA_COPILOT_HIDDEN_TOOLS,
|
||||
[
|
||||
"save_workflow",
|
||||
"create_workflow",
|
||||
"duplicate_flow",
|
||||
"edit_workflow"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accountability_uses_the_committed_snapshot_not_a_later_user_edit() {
|
||||
let before = vec![flow("target", "Deploy", graph_with_step("Ship it"))];
|
||||
let committed = flow("target", "Copilot edit", graph_with_step("Ship it twice"));
|
||||
let later_user_edit = flow("target", "User edit", graph_with_step("Something else"));
|
||||
|
||||
let changes = diff_workflow(&before, std::slice::from_ref(&committed), "target");
|
||||
assert!(
|
||||
changes.iter().any(|line| line.contains("Copilot edit")),
|
||||
"{changes:?}"
|
||||
);
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.all(|line| !line.contains(&later_user_edit.name)),
|
||||
"{changes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── the copilot turn's request shaping ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn naming_no_workflow_briefs_a_create() {
|
||||
let request = builder_request("build a deploy flow", None, &[]).unwrap();
|
||||
assert_eq!(request.mode, BuildMode::Create);
|
||||
assert!(request.graph.is_none());
|
||||
assert!(request.flow_id.is_none());
|
||||
assert_eq!(request.instruction, "build a deploy flow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_a_workflow_briefs_a_revise_of_that_exact_graph() {
|
||||
let flows = vec![flow("f1", "Deploy", graph_with_step("Ship it"))];
|
||||
let request = builder_request("add a retry", Some("f1"), &flows).unwrap();
|
||||
assert_eq!(request.mode, BuildMode::Revise);
|
||||
assert_eq!(request.flow_id.as_deref(), Some("f1"));
|
||||
assert_eq!(
|
||||
request.graph.unwrap(),
|
||||
serde_json::to_value(&flows[0].graph).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_workflow_id_fails_rather_than_silently_creating_a_second_one() {
|
||||
let err = builder_request("add a retry", Some("ghost"), &[]).unwrap_err();
|
||||
assert!(err.contains("ghost"), "{err}");
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
//! handful of functions re-exported below for the capability seam's
|
||||
//! [`crate::openhuman::tinyflows::caps::FlowStateStore`]); the RPC/CLI
|
||||
//! controller surface in `schemas` (private, re-exported below).
|
||||
//!
|
||||
//! [`medulla_bridge`] adapts this store onto the medulla harness protocol's
|
||||
//! workflow plane, so a remote orchestrator can read these graphs and brief the
|
||||
//! authoring copilot without any of that reaching back into `ops`.
|
||||
|
||||
pub mod agents;
|
||||
mod build_registry;
|
||||
@@ -13,6 +17,7 @@ pub mod builder_tools;
|
||||
pub mod bus;
|
||||
pub mod discovery_tools;
|
||||
mod draft_store;
|
||||
pub mod medulla_bridge;
|
||||
pub mod memory_tools;
|
||||
mod n8n_import;
|
||||
pub mod node_contracts;
|
||||
|
||||
@@ -3444,6 +3444,7 @@ pub async fn flows_duplicate(config: &Config, id: &str) -> Result<RpcOutcome<Flo
|
||||
store::insert_duplicate_flow(config, &source, new_name).map_err(|e| e.to_string())?;
|
||||
// Intentionally NO bind_trigger: a duplicate is disabled and must stay
|
||||
// inert (no schedule/trigger dispatch) until the user enables it.
|
||||
publish_flow_changed(&flow.id, "created", "system");
|
||||
Ok(RpcOutcome::single_log(
|
||||
flow,
|
||||
format!("flow duplicated from {id}"),
|
||||
@@ -3737,6 +3738,14 @@ fn publish_flow_changed(flow_id: &str, kind: &str, actor: &str) {
|
||||
kind: kind.to_string(),
|
||||
actor: actor.to_string(),
|
||||
});
|
||||
// Re-advertise the workflow set to the medulla backend. This is the single
|
||||
// funnel every store mutation passes through (create / duplicate / update /
|
||||
// delete / enable), and the backend replaces a socket's whole entry on each
|
||||
// registration — so re-sending here is what keeps a remote orchestrator from
|
||||
// reasoning about a set that no longer exists. A no-op (one debug log, no
|
||||
// task spawned) when no bridge is installed, which is every build that is
|
||||
// not talking to a backend, and every test.
|
||||
crate::openhuman::socket::medulla::workflows::emit_register_workflows();
|
||||
}
|
||||
|
||||
/// Maps a store-level [`FlowUpdateError`](store::FlowUpdateError) to the RPC
|
||||
@@ -3809,6 +3818,54 @@ pub async fn flows_update(
|
||||
graph_json: Option<Value>,
|
||||
require_approval: Option<bool>,
|
||||
expected_version: Option<String>,
|
||||
) -> Result<RpcOutcome<Flow>, String> {
|
||||
flows_update_inner(
|
||||
config,
|
||||
id,
|
||||
name,
|
||||
graph_json,
|
||||
require_approval,
|
||||
expected_version,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update a flow while atomically disarming any automatic-trigger graph.
|
||||
///
|
||||
/// Remote authoring surfaces use this variant so revising a schedule,
|
||||
/// app-event, or webhook flow never preserves a prior local opt-in to run the
|
||||
/// old graph. The same guarded store write persists the graph and
|
||||
/// `enabled=false`, so no trigger can observe the revised graph armed between
|
||||
/// two writes.
|
||||
pub(crate) async fn flows_update_disarming_automatic(
|
||||
config: &Config,
|
||||
id: &str,
|
||||
name: Option<String>,
|
||||
graph_json: Option<Value>,
|
||||
require_approval: Option<bool>,
|
||||
expected_version: Option<String>,
|
||||
) -> Result<RpcOutcome<Flow>, String> {
|
||||
flows_update_inner(
|
||||
config,
|
||||
id,
|
||||
name,
|
||||
graph_json,
|
||||
require_approval,
|
||||
expected_version,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn flows_update_inner(
|
||||
config: &Config,
|
||||
id: &str,
|
||||
name: Option<String>,
|
||||
graph_json: Option<Value>,
|
||||
require_approval: Option<bool>,
|
||||
expected_version: Option<String>,
|
||||
disarm_automatic: bool,
|
||||
) -> Result<RpcOutcome<Flow>, String> {
|
||||
let existing = store::get_flow(config, id)
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -3834,13 +3891,15 @@ pub async fn flows_update(
|
||||
let was_auto = trigger_is_automatic(&existing.graph);
|
||||
let now_auto = trigger_is_automatic(&graph);
|
||||
let is_manual_to_auto_transition = now_auto && !was_auto;
|
||||
let enabled_override = is_manual_to_auto_transition.then_some(false);
|
||||
let forced_automatic_disarm = disarm_automatic && now_auto;
|
||||
let enabled_override =
|
||||
(is_manual_to_auto_transition || forced_automatic_disarm).then_some(false);
|
||||
// Best-effort flag for the info log / result message below: whether the
|
||||
// flow *appeared* live going into this update. Not used for the
|
||||
// override decision itself (that's unconditional, see above) — only to
|
||||
// avoid telling the user "flow was auto-disabled" when it was already
|
||||
// disabled going in.
|
||||
let should_disarm = is_manual_to_auto_transition && existing.enabled;
|
||||
let should_disarm = enabled_override == Some(false) && existing.enabled;
|
||||
tracing::debug!(
|
||||
target: "flows",
|
||||
flow_id = %id,
|
||||
@@ -3848,6 +3907,7 @@ pub async fn flows_update(
|
||||
now_auto,
|
||||
currently_enabled = existing.enabled,
|
||||
is_manual_to_auto_transition,
|
||||
forced_automatic_disarm,
|
||||
should_disarm,
|
||||
"[flows] flows_update: auto-trigger disarm decision inputs"
|
||||
);
|
||||
@@ -3896,7 +3956,7 @@ pub async fn flows_update(
|
||||
tracing::info!(
|
||||
target: "flows",
|
||||
flow_id = %id,
|
||||
"[flows] flows_update: auto-disabled — graph changed manual→automatic trigger on an enabled flow"
|
||||
"[flows] flows_update: auto-disabled automatic-trigger graph pending explicit re-arm"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3914,12 +3974,16 @@ pub async fn flows_update(
|
||||
publish_flow_changed(id, "updated", "system");
|
||||
let mut logs = vec![format!("flow updated: {id}")];
|
||||
if should_disarm {
|
||||
logs.push(
|
||||
let reason = if forced_automatic_disarm {
|
||||
"Flow was auto-disabled because this authoring surface revised an automatic trigger \
|
||||
(schedule / app_event / webhook). Enable it explicitly (flows_set_enabled) once \
|
||||
you've reviewed the revision."
|
||||
} else {
|
||||
"Flow was auto-disabled because its trigger changed from manual to automatic \
|
||||
(schedule / app_event / webhook). Enable it explicitly (flows_set_enabled) once \
|
||||
you've reviewed the new trigger."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
logs.push(reason.to_string());
|
||||
}
|
||||
if side_effect_forced {
|
||||
logs.push(
|
||||
@@ -6165,6 +6229,21 @@ pub async fn flows_build(
|
||||
config: &Config,
|
||||
req: crate::openhuman::flows::agents::workflow_builder::builder_prompt::BuilderRequest,
|
||||
stream: Option<FlowStreamTarget>,
|
||||
) -> Result<RpcOutcome<Value>, String> {
|
||||
flows_build_with_extra_hidden_tools(config, req, stream, &[]).await
|
||||
}
|
||||
|
||||
/// [`flows_build`] with caller-specific tools removed in addition to the
|
||||
/// standard streaming/headless safety lists.
|
||||
///
|
||||
/// This is intentionally crate-private: product surfaces use [`flows_build`]'s
|
||||
/// normal builder belt. Host integrations that add their own persistence
|
||||
/// boundary can hide tools that would bypass that boundary.
|
||||
pub(crate) async fn flows_build_with_extra_hidden_tools(
|
||||
config: &Config,
|
||||
req: crate::openhuman::flows::agents::workflow_builder::builder_prompt::BuilderRequest,
|
||||
stream: Option<FlowStreamTarget>,
|
||||
extra_hidden_tools: &[&str],
|
||||
) -> Result<RpcOutcome<Value>, String> {
|
||||
use crate::openhuman::agent::Agent;
|
||||
use crate::openhuman::flows::agents::workflow_builder::builder_prompt::render_prompt;
|
||||
@@ -6225,6 +6304,14 @@ pub async fn flows_build(
|
||||
}
|
||||
restrict_builder_toolset(&mut agent);
|
||||
}
|
||||
if !extra_hidden_tools.is_empty() {
|
||||
tracing::debug!(
|
||||
target: "flows",
|
||||
hidden = ?extra_hidden_tools,
|
||||
"[flows] flows_build: applying caller-specific hidden tools"
|
||||
);
|
||||
agent.hide_tools(extra_hidden_tools);
|
||||
}
|
||||
|
||||
// When a chat thread is attached (the copilot pane), stream the builder turn
|
||||
// into it exactly like an interactive turn — text/tool deltas and the
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(super) fn handle_sio_event(
|
||||
match event_name {
|
||||
"ready" => {
|
||||
log::info!("[socket] Server ready — auth successful");
|
||||
super::medulla::workflows::begin_connection_generation();
|
||||
*shared.status.write() = ConnectionStatus::Connected;
|
||||
emit_state_change(shared);
|
||||
// Declare the device-tool manifest so the hosted brain knows which
|
||||
@@ -73,6 +74,12 @@ pub(super) fn handle_sio_event(
|
||||
// operator can delegate `medulla:task_run` to a named agent. The
|
||||
// backend clears the roster on socket disconnect.
|
||||
super::medulla::emit_register_agents();
|
||||
// Advertise the saved workflow graphs this host can be asked to run,
|
||||
// so the orchestrator can name one when delegating. Same
|
||||
// per-connection lifetime as the roster: rebuilt on every reconnect,
|
||||
// dropped server-side on disconnect. A no-op until a host installs a
|
||||
// `WorkflowBridge`.
|
||||
super::medulla::workflows::emit_register_workflows();
|
||||
}
|
||||
"error" => {
|
||||
log::error!("[socket] Server error event: {}", data);
|
||||
@@ -518,6 +525,52 @@ pub(super) fn handle_sio_event(
|
||||
Err(e) => log::warn!("[socket] failed to parse medulla:task_abort: {e}"),
|
||||
}
|
||||
}
|
||||
// Capability handshake. The backend waits 10s per probe, so an
|
||||
// unanswered one is not a graceful degradation — it is a stall on the
|
||||
// first delegation to this agent.
|
||||
"medulla:capabilities_request" => {
|
||||
match serde_json::from_value::<super::medulla::payloads::CapabilitiesRequest>(
|
||||
data.clone(),
|
||||
) {
|
||||
Ok(request) => {
|
||||
log::info!(
|
||||
"[socket] medulla:capabilities_request probe_id={} agent_id={}",
|
||||
request.probe_id,
|
||||
request.agent_id
|
||||
);
|
||||
super::medulla::handle_capabilities_request(request);
|
||||
}
|
||||
// An undecodable probe still has to be answered when it named
|
||||
// itself, for the same reason a decodable one does: silence
|
||||
// spends the backend's whole 10s window.
|
||||
Err(e) => {
|
||||
log::warn!("[socket] failed to parse medulla:capabilities_request: {e}");
|
||||
super::medulla::reject_unparsed_capabilities_request(&data, &e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Workflow round trip: a read of, or an authoring turn on, this host's
|
||||
// own workflow store.
|
||||
"medulla:workflow_request" => {
|
||||
match serde_json::from_value::<super::medulla::payloads::WorkflowRequest>(data.clone())
|
||||
{
|
||||
Ok(request) => {
|
||||
log::info!(
|
||||
"[socket] medulla:workflow_request request_id={} op={:?}",
|
||||
request.request_id,
|
||||
request.op
|
||||
);
|
||||
super::medulla::workflows::handle_workflow_request(request);
|
||||
}
|
||||
// An undecodable frame still has to be answered when it named
|
||||
// itself: staying silent would cost the backend the op's whole
|
||||
// deadline (up to ten minutes for `copilot`).
|
||||
Err(e) => {
|
||||
log::warn!("[socket] failed to parse medulla:workflow_request: {e}");
|
||||
super::medulla::workflows::reject_unparsed_request(&data, &e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel inbound message — publish to event bus for ChannelInboundSubscriber
|
||||
_ if event_name.ends_with(":message") => {
|
||||
|
||||
@@ -122,6 +122,9 @@ pub struct SocketManager {
|
||||
shutdown_tx: tokio::sync::Mutex<Option<watch::Sender<bool>>>,
|
||||
/// Join handle for the background connection loop.
|
||||
loop_handle: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
/// Serializes identity-sensitive disconnect → bridge bind → connect
|
||||
/// transactions while still allowing ordinary emits and state reads.
|
||||
identity_rebind: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl SocketManager {
|
||||
@@ -139,9 +142,15 @@ impl SocketManager {
|
||||
emit_tx: tokio::sync::Mutex::new(None),
|
||||
shutdown_tx: tokio::sync::Mutex::new(None),
|
||||
loop_handle: tokio::sync::Mutex::new(None),
|
||||
identity_rebind: tokio::sync::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock an identity-sensitive socket rebind for its complete transaction.
|
||||
pub(crate) async fn lock_identity_rebind(&self) -> tokio::sync::MutexGuard<'_, ()> {
|
||||
self.identity_rebind.lock().await
|
||||
}
|
||||
|
||||
/// Set the webhook router for skill-targeted webhook delivery.
|
||||
pub fn set_webhook_router(&self, router: Arc<WebhookRouter>) {
|
||||
log::debug!("[socket] WebhookRouter attached");
|
||||
@@ -272,13 +281,14 @@ impl SocketManager {
|
||||
|
||||
/// Disconnect from the server and shut down the background loop.
|
||||
pub async fn disconnect(&self) -> Result<(), String> {
|
||||
super::medulla::workflows::end_connection_generation();
|
||||
if let Some(tx) = self.shutdown_tx.lock().await.take() {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
self.shared.ack_registry.cancel_all();
|
||||
self.emit_tx.lock().await.take();
|
||||
if let Some(handle) = self.loop_handle.lock().await.take() {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
terminate_loop(handle, Duration::from_secs(5)).await;
|
||||
}
|
||||
*self.shared.status.write() = ConnectionStatus::Disconnected;
|
||||
*self.shared.socket_id.write() = None;
|
||||
@@ -337,6 +347,21 @@ impl SocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the socket loop to observe shutdown, then abort and join it if a
|
||||
/// transport operation outlives the grace period.
|
||||
///
|
||||
/// Dropping a timed-out `JoinHandle` detaches its task. During an account
|
||||
/// switch that would let the old credential finish authenticating after the
|
||||
/// new user's workflow bridge is installed, so timeout must mean termination,
|
||||
/// not detachment.
|
||||
async fn terminate_loop(mut handle: tokio::task::JoinHandle<()>, grace: Duration) {
|
||||
if tokio::time::timeout(grace, &mut handle).await.is_err() {
|
||||
log::warn!("[socket] connection loop did not stop within {grace:?} — aborting");
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_sio_event(
|
||||
event: &str,
|
||||
data: serde_json::Value,
|
||||
@@ -471,6 +496,56 @@ mod tests {
|
||||
assert_eq!(mgr.get_state().status, ConnectionStatus::Disconnected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_timed_out_socket_loop_is_aborted_and_joined() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
struct MarksDrop(Arc<AtomicBool>);
|
||||
impl Drop for MarksDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let dropped_in_task = Arc::clone(&dropped);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _guard = MarksDrop(dropped_in_task);
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
terminate_loop(handle, Duration::from_millis(1)).await;
|
||||
|
||||
assert!(
|
||||
dropped.load(Ordering::SeqCst),
|
||||
"terminate_loop must join the aborted task before returning"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_rebind_transactions_are_serialized() {
|
||||
let manager = Arc::new(SocketManager::new());
|
||||
let first = manager.lock_identity_rebind().await;
|
||||
|
||||
let waiting_manager = Arc::clone(&manager);
|
||||
let mut waiter = tokio::spawn(async move {
|
||||
let _second = waiting_manager.lock_identity_rebind().await;
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(10), &mut waiter)
|
||||
.await
|
||||
.is_err(),
|
||||
"a second account rebind must not interleave with the first"
|
||||
);
|
||||
|
||||
drop(first);
|
||||
tokio::time::timeout(Duration::from_secs(1), waiter)
|
||||
.await
|
||||
.expect("the next rebind should proceed after the first commits")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_state_change_is_safe_to_call_on_empty_shared() {
|
||||
let shared = SharedState {
|
||||
|
||||
@@ -12,15 +12,28 @@
|
||||
//! - `medulla:task_send` → [`MedullaTaskManager::steer_task`]
|
||||
//! - `medulla:task_abort` → [`MedullaTaskManager::abort_task`]
|
||||
//!
|
||||
//! - `medulla:capabilities_request` → [`handle_capabilities_request`]
|
||||
//! - `medulla:workflow_request` → [`workflows::handle_workflow_request`]
|
||||
//!
|
||||
//! Up (openhuman → backend):
|
||||
//! - `medulla:task_envelope` — the live session stream, as
|
||||
//! `tinyplace.harness.session.v2` envelopes (see [`envelope`]).
|
||||
//! - `medulla:task_result` — explicit completion.
|
||||
//! - `medulla:register_agents` — roster advertised on connect
|
||||
//! ([`emit_register_agents`]); the backend clears it on disconnect.
|
||||
//! - `medulla:register_workflows` — the saved workflow graphs this host can be
|
||||
//! asked to run ([`workflows::emit_register_workflows`]), same lifetime.
|
||||
//! - `medulla:capabilities_result` — the answer to a capability probe.
|
||||
//! - `medulla:workflow_result` — the answer to a workflow round trip.
|
||||
//!
|
||||
//! Every *down* event here is request/response with a server-side deadline, so
|
||||
//! silence is never free: an unanswered probe costs the backend ten seconds and
|
||||
//! an unanswered workflow request up to ten minutes. Both handlers therefore
|
||||
//! always reply, even when the answer is an error.
|
||||
|
||||
pub mod envelope;
|
||||
pub mod payloads;
|
||||
pub mod workflows;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
@@ -36,8 +49,8 @@ use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin};
|
||||
use crate::openhuman::agent::Agent;
|
||||
|
||||
use payloads::{
|
||||
AgentDescriptor, RegisterAgents, TaskResult, EVENT_REGISTER_AGENTS, EVENT_TASK_ENVELOPE,
|
||||
EVENT_TASK_RESULT,
|
||||
AgentDescriptor, CapabilitiesRequest, CapabilitiesResult, RegisterAgents, TaskResult,
|
||||
EVENT_CAPABILITIES_RESULT, EVENT_REGISTER_AGENTS, EVENT_TASK_ENVELOPE, EVENT_TASK_RESULT,
|
||||
};
|
||||
|
||||
/// Default agent an unspecified `medulla:task_run` runs as.
|
||||
@@ -476,7 +489,7 @@ pub fn emit_register_agents() {
|
||||
let agents: Vec<AgentDescriptor> = crate::openhuman::agent_registry::default_agents()
|
||||
.into_iter()
|
||||
.map(|entry| AgentDescriptor {
|
||||
agent_id: entry.id,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
})
|
||||
@@ -485,6 +498,130 @@ pub fn emit_register_agents() {
|
||||
emit(EVENT_REGISTER_AGENTS, RegisterAgents { agents });
|
||||
}
|
||||
|
||||
/// Handle `medulla:capabilities_request`: self-report for one probe.
|
||||
///
|
||||
/// The backend's roster calls this lazily before first delegating to an agent it
|
||||
/// has not cached, fans the frame out to every harness socket the user holds,
|
||||
/// and waits ten seconds. An agent that never answers does not degrade
|
||||
/// gracefully — it spends that whole window on every first delegation. So this
|
||||
/// always emits a `medulla:capabilities_result`, even when the only thing it can
|
||||
/// truthfully say is `ready`.
|
||||
///
|
||||
/// Runs on its own task: building the report loads config and reads the workflow
|
||||
/// store, neither of which belongs on the socket read loop.
|
||||
pub fn handle_capabilities_request(request: CapabilitiesRequest) {
|
||||
let workflows::BridgeGeneration { bridge, cancel } = workflows::bridge_generation();
|
||||
let connection_cancel = workflows::connection_generation();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => {
|
||||
log::debug!("[medulla] discarded capability probe from an old bridge");
|
||||
}
|
||||
_ = connection_cancel.cancelled() => {
|
||||
log::debug!("[medulla] discarded capability probe from a closed socket");
|
||||
}
|
||||
_ = async move {
|
||||
let capabilities = describe_self(&request.agent_id, bridge).await;
|
||||
emit_awaited(
|
||||
EVENT_CAPABILITIES_RESULT,
|
||||
CapabilitiesResult {
|
||||
probe_id: request.probe_id,
|
||||
capabilities,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
} => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Answer a `medulla:capabilities_request` this build could not decode.
|
||||
///
|
||||
/// A frame from a newer backend (an added required field, a retyped one) would
|
||||
/// otherwise be dropped, and a dropped probe costs the backend its full
|
||||
/// ten-second deadline on the *first* delegation to this agent. The `probeId` is
|
||||
/// recovered from the raw JSON — without one there is nothing to correlate, so
|
||||
/// the frame really is unanswerable and is only logged.
|
||||
pub fn reject_unparsed_capabilities_request(raw: &serde_json::Value, reason: &str) {
|
||||
let Some(result) = unparsed_capabilities_result(raw, reason) else {
|
||||
log::warn!("[medulla] undecodable capabilities_request carries no probeId — cannot answer");
|
||||
return;
|
||||
};
|
||||
log::info!(
|
||||
"[medulla] answering undecodable capabilities_request probe_id={} as not ready",
|
||||
result.probe_id
|
||||
);
|
||||
emit(EVENT_CAPABILITIES_RESULT, result);
|
||||
}
|
||||
|
||||
/// Project an undecodable probe onto its reply frame. Split out so the recovery
|
||||
/// (which `probeId` is answerable, and what the answer says) is testable without
|
||||
/// a socket.
|
||||
///
|
||||
/// The report is `ready: false` plus a reason rather than an empty bag: those
|
||||
/// two fields are on the backend's `sanitizeCapabilities` allowlist, and a
|
||||
/// payload with nothing on that allowlist sanitizes away into the same
|
||||
/// non-answer as silence.
|
||||
fn unparsed_capabilities_result(
|
||||
raw: &serde_json::Value,
|
||||
reason: &str,
|
||||
) -> Option<CapabilitiesResult> {
|
||||
let probe_id = raw.get("probeId").and_then(serde_json::Value::as_str)?;
|
||||
Some(CapabilitiesResult {
|
||||
probe_id: probe_id.to_string(),
|
||||
capabilities: serde_json::json!({
|
||||
"ready": false,
|
||||
"readyReason": format!("this agent could not read the probe: {reason}"),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build this host's capability report for `agent_id`.
|
||||
///
|
||||
/// Only fields on the backend's allowlist (`sanitizeCapabilities`) are worth
|
||||
/// sending — anything else is dropped there — and each is best-effort: a field
|
||||
/// this host cannot resolve is omitted rather than guessed, because the
|
||||
/// orchestrator reasons about placement from these values.
|
||||
async fn describe_self(
|
||||
agent_id: &str,
|
||||
bridge: Option<Arc<dyn workflows::WorkflowBridge>>,
|
||||
) -> serde_json::Value {
|
||||
let mut caps = serde_json::Map::new();
|
||||
// Advisory readiness. A connected core that answered the probe at all is
|
||||
// ready by definition; per-agent gating lives in the task path, not here.
|
||||
caps.insert("ready".to_string(), serde_json::Value::Bool(true));
|
||||
|
||||
if let Some(entry) = crate::openhuman::agent_registry::default_agents()
|
||||
.into_iter()
|
||||
.find(|entry| entry.id == agent_id)
|
||||
{
|
||||
if !entry.description.is_empty() {
|
||||
caps.insert("summary".to_string(), entry.description.into());
|
||||
}
|
||||
}
|
||||
|
||||
// `cwd` is the agent's read/write root (`action_dir`), which is what the
|
||||
// orchestrator actually means by "where does this agent work".
|
||||
if let Some(action_dir) = bridge.as_ref().and_then(|bridge| bridge.action_dir()) {
|
||||
caps.insert("cwd".to_string(), action_dir.into());
|
||||
}
|
||||
|
||||
// The workflow adverts ride the probe as well as the push registration, so a
|
||||
// backend that only probes still learns this host's graphs.
|
||||
let workflows = workflows::advertised_workflows_for(bridge).await;
|
||||
if !workflows.is_empty() {
|
||||
match serde_json::to_value(workflows) {
|
||||
Ok(value) => {
|
||||
caps.insert("workflows".to_string(), value);
|
||||
}
|
||||
Err(err) => log::warn!("[medulla] failed to serialize workflow adverts: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::Value::Object(caps)
|
||||
}
|
||||
|
||||
/// Serialize `payload` and emit it as a Socket.IO event on the global backend
|
||||
/// socket. Best-effort: a missing/disconnected socket is logged, not fatal.
|
||||
fn emit<T: serde::Serialize>(event: &str, payload: T) {
|
||||
@@ -508,6 +645,32 @@ fn emit<T: serde::Serialize>(event: &str, payload: T) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Serialize and enqueue one medulla event before returning.
|
||||
///
|
||||
/// Registration snapshots use this awaited form because their full-replacement
|
||||
/// ordering must extend through `SocketManager::emit`'s channel send, not stop
|
||||
/// at spawning a task that may be scheduled later.
|
||||
async fn emit_awaited<T: serde::Serialize>(event: &str, payload: T) -> bool {
|
||||
let data = match serde_json::to_value(&payload) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
log::warn!("[medulla] failed to serialize payload for {event}: {err}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let Some(manager) = crate::openhuman::socket::global_socket_manager() else {
|
||||
log::debug!("[medulla] no socket manager — dropping {event}");
|
||||
return false;
|
||||
};
|
||||
match manager.emit(event, data).await {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::warn!("[medulla] failed to emit {event}: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -563,6 +726,31 @@ mod tests {
|
||||
assert_eq!(key, format!("orchestrator_{}", "x".repeat(32)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undecodable_probe_answers_not_ready_when_it_names_itself() {
|
||||
let raw = serde_json::json!({ "probeId": "p-1", "agentId": 7 });
|
||||
let result = unparsed_capabilities_result(&raw, "invalid type: integer")
|
||||
.expect("a probe that names itself is answerable");
|
||||
assert_eq!(result.probe_id, "p-1");
|
||||
// `ready` + `readyReason` are the two fields the backend's allowlist
|
||||
// keeps; an answer outside them sanitizes to an empty bag, which the
|
||||
// probe treats as no answer at all.
|
||||
assert_eq!(result.capabilities["ready"], false);
|
||||
assert!(result.capabilities["readyReason"]
|
||||
.as_str()
|
||||
.expect("a readable reason")
|
||||
.contains("invalid type: integer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undecodable_probe_without_a_probe_id_is_unanswerable() {
|
||||
// Nothing to correlate, so nothing can be answered — and the socket read
|
||||
// loop must not panic over it either.
|
||||
let raw = serde_json::json!({ "agentId": "orchestrator" });
|
||||
assert!(unparsed_capabilities_result(&raw, "missing field `probeId`").is_none());
|
||||
reject_unparsed_capabilities_request(&raw, "missing field `probeId`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_budget_reports_time_left_and_exhaustion() {
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -80,10 +80,16 @@ pub struct TaskResult {
|
||||
}
|
||||
|
||||
/// A single agent descriptor advertised in the roster.
|
||||
///
|
||||
/// The identity key is `id`, NOT `agentId`: the backend's roster registry keys
|
||||
/// and validates on `id` (`agentRegistry.ts` `hasValidId`), and medulla-v1's
|
||||
/// `AgentDescriptor` port declares the same field. An advert that names its
|
||||
/// agent under any other key is silently dropped at the backend boundary, so
|
||||
/// this must stay `id` on the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentDescriptor {
|
||||
pub agent_id: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
@@ -97,6 +103,167 @@ pub struct RegisterAgents {
|
||||
pub agents: Vec<AgentDescriptor>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Capability handshake
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `medulla:capabilities_request` — the backend asks one agent to self-report.
|
||||
///
|
||||
/// The backend fans this out to every harness socket the user holds and
|
||||
/// correlates the answer by `probe_id`; an agent that never answers costs the
|
||||
/// probe its full ten-second deadline, so [`super::emit_capabilities_result`]
|
||||
/// always replies, even when it has nothing interesting to say.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CapabilitiesRequest {
|
||||
pub probe_id: String,
|
||||
#[serde(default)]
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
/// `medulla:capabilities_result` — the self-report for one probe.
|
||||
///
|
||||
/// `capabilities` is an open bag the backend narrows through its own allowlist
|
||||
/// (`sanitizeCapabilities`), so it stays raw JSON here rather than a struct this
|
||||
/// side would have to keep in lockstep with the server's allowlist.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CapabilitiesResult {
|
||||
pub probe_id: String,
|
||||
pub capabilities: serde_json::Value,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Workflow plane
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One saved workflow graph as advertised to the backend — enough to choose it
|
||||
/// and explain the choice, never the graph itself.
|
||||
///
|
||||
/// Field-compatible with the tiny.place `WorkflowAdvert`
|
||||
/// (`medulla-public/src/sdk/src/tinyplace/frames/types.rs`) and with the
|
||||
/// `WorkflowDescriptor` port in medulla-v1, so an advert crossing this boundary
|
||||
/// needs no translation layer on either side. `name`/`description` skip
|
||||
/// serialization when empty for exactly that reason: the Rust advert already
|
||||
/// omits its empty strings, the port therefore declares them optional, and the
|
||||
/// backend passes an absent key through as absent rather than fabricating `""`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkflowDescriptor {
|
||||
/// Stable identity — the only field the wire always carries and the only one
|
||||
/// anything routes on (delegation, `get`, `runs`).
|
||||
pub id: String,
|
||||
/// Display name; omitted from the wire when blank.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
/// Prompt surface — written like a tool description; omitted when blank.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
/// How many steps the graph has: a rough cost signal, rendered verbatim.
|
||||
#[serde(default)]
|
||||
pub node_count: u32,
|
||||
/// Whether this host considers the workflow runnable right now. Advisory —
|
||||
/// the reader applies no policy, this host is the one that refuses.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
/// Open vocabulary for what starts the workflow ("manual", "cron", …).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trigger_kind: Option<String>,
|
||||
/// Provenance: which roster agent owns this workflow. Usually left unset —
|
||||
/// the backend stamps the batch-level `agentId` onto any advert without one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<String>,
|
||||
/// Provenance: the workspace the owning agent is deployed into.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `medulla:register_workflows` — the workflow advert batch, sent on connect and
|
||||
/// re-sent whenever the host's store changes. The backend replaces this socket's
|
||||
/// whole entry each time and drops it on disconnect, so a shrinking store is
|
||||
/// communicated by re-sending the smaller list (never by a delete event).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisterWorkflows {
|
||||
pub workflows: Vec<WorkflowDescriptor>,
|
||||
/// Batch-level provenance stamped onto adverts that do not name their own
|
||||
/// owner. Absent means "this host, agent unspecified".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Which read (or the one authoring turn) a `medulla:workflow_request` asks for.
|
||||
///
|
||||
/// Snake-case on the wire to match the backend's `WorkflowRequestOp` union.
|
||||
/// Unknown ops are a decode failure rather than a silent no-op, so a version
|
||||
/// skew shows up as a reported error instead of a ten-second server timeout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkflowOp {
|
||||
/// Fetch one workflow's detail, including its graph.
|
||||
Get,
|
||||
/// The node-kind catalog, optionally filtered to one `kind`.
|
||||
NodeKinds,
|
||||
/// Recent runs of one workflow.
|
||||
Runs,
|
||||
/// A whole authoring turn on this host's own copilot.
|
||||
Copilot,
|
||||
}
|
||||
|
||||
/// `medulla:workflow_request` — one round trip addressed to the host that
|
||||
/// advertised the workflow. Which optional fields matter depends on `op`:
|
||||
/// `get`/`runs` read `workflow_id`, `node_kinds` reads `kind`, `copilot` reads
|
||||
/// `instruction` plus an optional `workflow_id` (absent ⇒ create).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkflowRequest {
|
||||
pub request_id: String,
|
||||
pub op: WorkflowOp,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instruction: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `medulla:workflow_result` — the answer to one round trip.
|
||||
///
|
||||
/// `data` is OPAQUE: a graph, a node-kind catalog and a copilot outcome are all
|
||||
/// host-shaped and pass through the backend verbatim (only size-bounded, at
|
||||
/// 1 MiB). `ok: false` carries a readable message the orchestrator renders as a
|
||||
/// tool error — a failed read must still be *answered*, because a dropped
|
||||
/// request costs the server its whole deadline instead.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkflowResult {
|
||||
pub request_id: String,
|
||||
pub ok: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// What a copilot turn reports back: the assistant's reply, the changes it
|
||||
/// actually made (derived by the host from a re-read of its own store, never
|
||||
/// from the model's claim), and the id of a workflow it created.
|
||||
///
|
||||
/// Mirrors the `CopilotOutcome` medulla-public's authoring copilot already
|
||||
/// returns and the `WorkflowCopilotOutcome` the library port declares, so it
|
||||
/// crosses the seam unchanged.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CopilotOutcome {
|
||||
pub reply: String,
|
||||
#[serde(default)]
|
||||
pub changes: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<String>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Socket.IO event names
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -105,11 +272,16 @@ pub struct RegisterAgents {
|
||||
pub const EVENT_TASK_RUN: &str = "medulla:task_run";
|
||||
pub const EVENT_TASK_SEND: &str = "medulla:task_send";
|
||||
pub const EVENT_TASK_ABORT: &str = "medulla:task_abort";
|
||||
pub const EVENT_CAPABILITIES_REQUEST: &str = "medulla:capabilities_request";
|
||||
pub const EVENT_WORKFLOW_REQUEST: &str = "medulla:workflow_request";
|
||||
|
||||
/// Up events emitted by openhuman.
|
||||
pub const EVENT_TASK_ENVELOPE: &str = "medulla:task_envelope";
|
||||
pub const EVENT_TASK_RESULT: &str = "medulla:task_result";
|
||||
pub const EVENT_REGISTER_AGENTS: &str = "medulla:register_agents";
|
||||
pub const EVENT_REGISTER_WORKFLOWS: &str = "medulla:register_workflows";
|
||||
pub const EVENT_CAPABILITIES_RESULT: &str = "medulla:capabilities_result";
|
||||
pub const EVENT_WORKFLOW_RESULT: &str = "medulla:workflow_result";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -177,17 +349,147 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_agents_round_trips() {
|
||||
fn register_agents_advertises_the_id_key_the_backend_validates() {
|
||||
let roster = RegisterAgents {
|
||||
agents: vec![AgentDescriptor {
|
||||
agent_id: "orchestrator".into(),
|
||||
id: "orchestrator".into(),
|
||||
name: "Orchestrator".into(),
|
||||
description: "default".into(),
|
||||
}],
|
||||
};
|
||||
let wire = serde_json::to_value(&roster).unwrap();
|
||||
assert_eq!(wire["agents"][0]["agentId"], "orchestrator");
|
||||
// `agentRegistry.hasValidId` keys on `id`; an `agentId` key here would
|
||||
// make the whole roster vanish server-side.
|
||||
assert_eq!(wire["agents"][0]["id"], "orchestrator");
|
||||
assert!(wire["agents"][0].get("agentId").is_none());
|
||||
let back: RegisterAgents = serde_json::from_value(wire).unwrap();
|
||||
assert_eq!(roster, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_descriptor_omits_blank_name_and_description() {
|
||||
let advert = WorkflowDescriptor {
|
||||
id: "wf-1".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
node_count: 5,
|
||||
enabled: Some(true),
|
||||
trigger_kind: Some("cron".into()),
|
||||
agent_id: None,
|
||||
workspace_id: None,
|
||||
};
|
||||
let wire = serde_json::to_value(&advert).unwrap();
|
||||
assert_eq!(wire["id"], "wf-1");
|
||||
assert_eq!(wire["nodeCount"], 5);
|
||||
assert_eq!(wire["enabled"], true);
|
||||
assert_eq!(wire["triggerKind"], "cron");
|
||||
// Absent, never `""` — the port declares these optional precisely
|
||||
// because the wire omits them.
|
||||
assert!(wire.get("name").is_none());
|
||||
assert!(wire.get("description").is_none());
|
||||
assert!(wire.get("agentId").is_none());
|
||||
let back: WorkflowDescriptor = serde_json::from_value(wire).unwrap();
|
||||
assert_eq!(advert, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_workflows_omits_absent_batch_agent_id() {
|
||||
let batch = RegisterWorkflows {
|
||||
workflows: vec![],
|
||||
agent_id: None,
|
||||
};
|
||||
let wire = serde_json::to_value(&batch).unwrap();
|
||||
assert!(wire["workflows"].as_array().unwrap().is_empty());
|
||||
assert!(wire.get("agentId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_request_reads_every_op_from_the_wire() {
|
||||
for (wire_op, expected) in [
|
||||
("get", WorkflowOp::Get),
|
||||
("node_kinds", WorkflowOp::NodeKinds),
|
||||
("runs", WorkflowOp::Runs),
|
||||
("copilot", WorkflowOp::Copilot),
|
||||
] {
|
||||
let parsed: WorkflowRequest =
|
||||
serde_json::from_value(json!({ "requestId": "r1", "op": wire_op })).unwrap();
|
||||
assert_eq!(parsed.op, expected);
|
||||
assert_eq!(parsed.request_id, "r1");
|
||||
assert!(parsed.workflow_id.is_none());
|
||||
}
|
||||
// An op this build does not know is a decode error, not a silent drop.
|
||||
assert!(serde_json::from_value::<WorkflowRequest>(
|
||||
json!({ "requestId": "r1", "op": "apply_ops" })
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_request_reads_the_op_specific_fields() {
|
||||
let parsed: WorkflowRequest = serde_json::from_value(json!({
|
||||
"requestId": "r2",
|
||||
"op": "copilot",
|
||||
"instruction": "add a slack step",
|
||||
"workflowId": "wf-1",
|
||||
"agentId": "orchestrator",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(parsed.op, WorkflowOp::Copilot);
|
||||
assert_eq!(parsed.instruction.as_deref(), Some("add a slack step"));
|
||||
assert_eq!(parsed.workflow_id.as_deref(), Some("wf-1"));
|
||||
assert_eq!(parsed.agent_id.as_deref(), Some("orchestrator"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_result_omits_the_unused_arm() {
|
||||
let ok = WorkflowResult {
|
||||
request_id: "r".into(),
|
||||
ok: true,
|
||||
data: Some(json!({ "graph": [] })),
|
||||
error: None,
|
||||
};
|
||||
let wire = serde_json::to_value(&ok).unwrap();
|
||||
assert_eq!(wire["requestId"], "r");
|
||||
assert_eq!(wire["ok"], true);
|
||||
assert!(wire.get("error").is_none());
|
||||
|
||||
let failed = WorkflowResult {
|
||||
request_id: "r".into(),
|
||||
ok: false,
|
||||
data: None,
|
||||
error: Some("unknown workflow".into()),
|
||||
};
|
||||
let wire = serde_json::to_value(&failed).unwrap();
|
||||
assert_eq!(wire["ok"], false);
|
||||
assert_eq!(wire["error"], "unknown workflow");
|
||||
assert!(wire.get("data").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copilot_outcome_matches_the_library_shape() {
|
||||
let outcome = CopilotOutcome {
|
||||
reply: "added the step".into(),
|
||||
changes: vec!["node:slack added".into()],
|
||||
created: None,
|
||||
};
|
||||
let wire = serde_json::to_value(&outcome).unwrap();
|
||||
assert_eq!(wire["reply"], "added the step");
|
||||
assert_eq!(wire["changes"][0], "node:slack added");
|
||||
assert!(wire.get("created").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_request_defaults_a_missing_agent_id() {
|
||||
let parsed: CapabilitiesRequest =
|
||||
serde_json::from_value(json!({ "probeId": "p1" })).unwrap();
|
||||
assert_eq!(parsed.probe_id, "p1");
|
||||
assert!(parsed.agent_id.is_empty());
|
||||
let wire = serde_json::to_value(CapabilitiesResult {
|
||||
probe_id: "p1".into(),
|
||||
capabilities: json!({ "ready": true }),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(wire["probeId"], "p1");
|
||||
assert_eq!(wire["capabilities"]["ready"], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,872 @@
|
||||
//! The workflow plane of the medulla harness protocol: advertising this host's
|
||||
//! saved workflow graphs, and serving the reads (and the one authoring turn)
|
||||
//! the orchestrator round-trips back to whoever owns them.
|
||||
//!
|
||||
//! **Why a trait and not a direct call into `flows`.** Two different hosts embed
|
||||
//! this transport — openhuman over its SQLite `flows` store, and medulla-public
|
||||
//! over its own layered JSON workflow store — and they share nothing but the
|
||||
//! wire shape. So the store side is a host-supplied [`WorkflowBridge`] the host
|
||||
//! installs once at startup ([`set_workflow_bridge`]), and the engine types stay
|
||||
//! entirely on the host's side of the seam: a graph, a node-kind catalog and a
|
||||
//! run list all cross as opaque [`serde_json::Value`], exactly as they do in
|
||||
//! medulla-v1's `WorkflowCatalog` port. Nothing in this module parses a node, an
|
||||
//! edge, or a config field.
|
||||
//!
|
||||
//! **Why every path answers.** The server correlates a request by `requestId`
|
||||
//! and waits on a deadline — ten seconds for a read, ten *minutes* for a copilot
|
||||
//! turn. A dropped request is therefore not a cheap no-op: it burns that whole
|
||||
//! window and, for `copilot`, holds a promise open for the rest of the cycle. So
|
||||
//! a missing bridge, an unknown op argument, a bridge error and even a panicking
|
||||
//! bridge method all become a `medulla:workflow_result` with `ok: false` and a
|
||||
//! readable message. The only way to stay silent is for the socket to be gone,
|
||||
//! which the server already handles by retiring the waiter on disconnect.
|
||||
|
||||
use std::future::Future;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::FutureExt;
|
||||
use parking_lot::RwLock;
|
||||
use serde_json::Value;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::payloads::{
|
||||
CopilotOutcome, RegisterWorkflows, WorkflowDescriptor, WorkflowOp, WorkflowRequest,
|
||||
WorkflowResult, EVENT_REGISTER_WORKFLOWS, EVENT_WORKFLOW_RESULT,
|
||||
};
|
||||
|
||||
/// The store side of the workflow plane, supplied by the embedding host.
|
||||
///
|
||||
/// The reads are synchronous because every host backs them with a local store;
|
||||
/// they are dispatched on a blocking thread so a slow store cannot stall the
|
||||
/// socket runtime. `copilot` is `async` because it is not a read at all — it is
|
||||
/// a whole agent turn on the host's own authoring copilot.
|
||||
///
|
||||
/// Every method returns `Result<_, String>` rather than a host error type: the
|
||||
/// error is going straight onto a model's prompt surface as a tool result, so it
|
||||
/// must already be a sentence a reader can act on.
|
||||
#[async_trait]
|
||||
pub trait WorkflowBridge: Send + Sync {
|
||||
/// The adverts this host publishes. Cheap and side-effect free — it is
|
||||
/// called on every (re)connect and on every store change.
|
||||
fn list(&self) -> Vec<WorkflowDescriptor>;
|
||||
|
||||
/// Fallible advert read used by registration.
|
||||
///
|
||||
/// The default preserves compatibility for hosts whose list cannot fail.
|
||||
/// Store-backed hosts override it so a transient read failure does not look
|
||||
/// like a successful empty replacement batch.
|
||||
fn try_list(&self) -> Result<Vec<WorkflowDescriptor>, String> {
|
||||
Ok(self.list())
|
||||
}
|
||||
|
||||
/// One workflow's detail, graph included, as the host's own JSON shape.
|
||||
fn get(&self, id: &str) -> Result<Value, String>;
|
||||
|
||||
/// The node-kind catalog, optionally narrowed to a single `kind`. This is an
|
||||
/// authoring aid: a manager reads it to brief the copilot accurately.
|
||||
fn node_kinds(&self, kind: Option<&str>) -> Result<Value, String>;
|
||||
|
||||
/// Recent runs of one workflow, for visibility into a delegated graph.
|
||||
fn runs(&self, id: &str) -> Result<Value, String>;
|
||||
|
||||
/// Run one authoring turn on the host's copilot. `workflow_id` absent means
|
||||
/// "create a new workflow". The outcome must be derived from a re-read of
|
||||
/// the host's own store rather than from the model's claim about what it
|
||||
/// did — that is the accountability property the whole delegation rests on.
|
||||
async fn copilot(
|
||||
&self,
|
||||
instruction: &str,
|
||||
workflow_id: Option<&str>,
|
||||
) -> Result<CopilotOutcome, String>;
|
||||
|
||||
/// Which roster agent owns these workflows, when the host runs more than
|
||||
/// one identity. Stamped onto the advert batch so the backend can route a
|
||||
/// delegated workflow to its owner. Defaults to unset — the backend then
|
||||
/// treats the whole socket as the owner.
|
||||
fn agent_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The action directory associated with this bridge's authenticated
|
||||
/// identity, when the host has one. Capability probes use this instead of
|
||||
/// reloading process-global active-user state.
|
||||
fn action_dir(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct BridgeGeneration {
|
||||
pub(super) bridge: Option<Arc<dyn WorkflowBridge>>,
|
||||
pub(super) cancel: CancellationToken,
|
||||
}
|
||||
|
||||
struct BridgeState {
|
||||
bridge: Option<Arc<dyn WorkflowBridge>>,
|
||||
generation: CancellationToken,
|
||||
}
|
||||
|
||||
struct ConnectionGeneration {
|
||||
current: CancellationToken,
|
||||
}
|
||||
|
||||
impl ConnectionGeneration {
|
||||
fn disconnected() -> Self {
|
||||
let current = CancellationToken::new();
|
||||
current.cancel();
|
||||
Self { current }
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> CancellationToken {
|
||||
self.current.clone()
|
||||
}
|
||||
|
||||
fn begin(&mut self) {
|
||||
self.current.cancel();
|
||||
self.current = CancellationToken::new();
|
||||
}
|
||||
|
||||
fn end(&mut self) {
|
||||
self.current.cancel();
|
||||
*self = Self::disconnected();
|
||||
}
|
||||
}
|
||||
|
||||
static BRIDGE_STATE: OnceLock<RwLock<BridgeState>> = OnceLock::new();
|
||||
static CONNECTION_GENERATION: OnceLock<RwLock<ConnectionGeneration>> = OnceLock::new();
|
||||
|
||||
fn bridge_state() -> &'static RwLock<BridgeState> {
|
||||
BRIDGE_STATE.get_or_init(|| {
|
||||
RwLock::new(BridgeState {
|
||||
bridge: None,
|
||||
generation: CancellationToken::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn connection_generation_state() -> &'static RwLock<ConnectionGeneration> {
|
||||
CONNECTION_GENERATION.get_or_init(|| RwLock::new(ConnectionGeneration::disconnected()))
|
||||
}
|
||||
|
||||
pub(super) fn bridge_generation() -> BridgeGeneration {
|
||||
let state = bridge_state().read();
|
||||
BridgeGeneration {
|
||||
bridge: state.bridge.clone(),
|
||||
cancel: state.generation.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn connection_generation() -> CancellationToken {
|
||||
connection_generation_state().read().snapshot()
|
||||
}
|
||||
|
||||
/// Start a new authenticated-socket lifetime.
|
||||
///
|
||||
/// Called by the `ready` handler before it advertises workflows. Work received
|
||||
/// before `ready`, while reconnecting, or after a disconnect sees a cancelled
|
||||
/// generation and is discarded instead of outliving the backend waiter that
|
||||
/// sent it.
|
||||
pub(in crate::openhuman::socket) fn begin_connection_generation() {
|
||||
connection_generation_state().write().begin();
|
||||
}
|
||||
|
||||
/// End the current authenticated-socket lifetime and cancel its work.
|
||||
pub(in crate::openhuman::socket) fn end_connection_generation() {
|
||||
connection_generation_state().write().end();
|
||||
}
|
||||
|
||||
fn replace_bridge(bridge: Option<Arc<dyn WorkflowBridge>>) -> CancellationToken {
|
||||
let mut state = bridge_state().write();
|
||||
state.generation.cancel();
|
||||
let generation = CancellationToken::new();
|
||||
state.bridge = bridge;
|
||||
state.generation = generation.clone();
|
||||
generation
|
||||
}
|
||||
|
||||
/// Orders overlapping snapshot emissions by successful registration revision.
|
||||
///
|
||||
/// Reads stay concurrent and off the socket runtime. A newer successful read
|
||||
/// supersedes every older one, while a newer failed read does not suppress an
|
||||
/// older successful snapshot. The mutex makes the revision check plus the
|
||||
/// awaited socket enqueue one ordered operation.
|
||||
struct RegistrationSequencer {
|
||||
next_revision: AtomicU64,
|
||||
last_emitted: tokio::sync::Mutex<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SequencedEmit {
|
||||
Emitted,
|
||||
Superseded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl RegistrationSequencer {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
next_revision: AtomicU64::new(0),
|
||||
last_emitted: tokio::sync::Mutex::const_new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn begin(&self) -> u64 {
|
||||
self.next_revision.fetch_add(1, Ordering::Relaxed) + 1
|
||||
}
|
||||
|
||||
async fn emit_if_newer<F, Fut>(&self, revision: u64, emit: F) -> SequencedEmit
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = bool>,
|
||||
{
|
||||
let mut last_emitted = self.last_emitted.lock().await;
|
||||
if revision <= *last_emitted {
|
||||
return SequencedEmit::Superseded;
|
||||
}
|
||||
if !emit().await {
|
||||
return SequencedEmit::Failed;
|
||||
}
|
||||
*last_emitted = revision;
|
||||
SequencedEmit::Emitted
|
||||
}
|
||||
}
|
||||
|
||||
static REGISTRATION_SEQUENCE: RegistrationSequencer = RegistrationSequencer::new();
|
||||
|
||||
/// Install (or replace) the host's workflow bridge, and advertise what it holds.
|
||||
///
|
||||
/// Registering re-advertises immediately so a bridge installed after connect
|
||||
/// still reaches a backend that has already seen this socket's `ready`.
|
||||
pub fn set_workflow_bridge(bridge: Arc<dyn WorkflowBridge>) {
|
||||
replace_bridge(Some(bridge));
|
||||
emit_register_workflows();
|
||||
}
|
||||
|
||||
/// Remove the installed bridge and tell the backend this socket now advertises
|
||||
/// nothing. Sending an empty batch is the only way to retract: the backend
|
||||
/// replaces a socket's whole entry on each registration and has no delete event.
|
||||
pub fn clear_workflow_bridge() {
|
||||
let generation = replace_bridge(None);
|
||||
let connection_cancel = connection_generation();
|
||||
let revision = REGISTRATION_SEQUENCE.begin();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = generation.cancelled() => {}
|
||||
_ = connection_cancel.cancelled() => {}
|
||||
_ = async move {
|
||||
let batch = RegisterWorkflows {
|
||||
workflows: Vec::new(),
|
||||
agent_id: None,
|
||||
};
|
||||
REGISTRATION_SEQUENCE
|
||||
.emit_if_newer(revision, || emit_batch(batch))
|
||||
.await;
|
||||
} => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Emit `medulla:register_workflows` — the advert batch, sent on connect and
|
||||
/// whenever the host signals its workflow set changed.
|
||||
///
|
||||
/// A host without a bridge stays silent rather than advertising an empty list:
|
||||
/// "I have no workflow plane" and "my store is empty" are different claims, and
|
||||
/// only the second one should clear a previously registered set.
|
||||
pub fn emit_register_workflows() {
|
||||
let generation = bridge_generation();
|
||||
let Some(bridge) = generation.bridge else {
|
||||
log::debug!("[medulla] no workflow bridge installed — not advertising workflows");
|
||||
return;
|
||||
};
|
||||
let revision = REGISTRATION_SEQUENCE.begin();
|
||||
let cancel = generation.cancel;
|
||||
let connection_cancel = connection_generation();
|
||||
// `list()` reads the host's store, so keep it off the socket runtime; a
|
||||
// panicking bridge surfaces as a `JoinError` here instead of unwinding.
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => {
|
||||
log::debug!("[medulla] discarded workflow registration from an old bridge");
|
||||
}
|
||||
_ = connection_cancel.cancelled() => {
|
||||
log::debug!("[medulla] discarded workflow registration from a closed socket");
|
||||
}
|
||||
_ = async move {
|
||||
match read_batch(bridge).await {
|
||||
Ok(batch) => {
|
||||
let count = batch.workflows.len();
|
||||
match REGISTRATION_SEQUENCE
|
||||
.emit_if_newer(revision, || emit_batch(batch))
|
||||
.await
|
||||
{
|
||||
SequencedEmit::Emitted => {
|
||||
log::info!("[medulla] advertised {count} workflows to backend");
|
||||
}
|
||||
SequencedEmit::Superseded => log::debug!(
|
||||
"[medulla] workflow registration snapshot already superseded by a newer \
|
||||
successful read — not advertising"
|
||||
),
|
||||
SequencedEmit::Failed => {
|
||||
log::debug!("[medulla] workflow registration enqueue failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[medulla] workflow bridge list() failed — not advertising: {err}")
|
||||
}
|
||||
}
|
||||
} => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The adverts this host currently publishes, for callers that need the list
|
||||
/// itself rather than the registration frame — today the capability probe, which
|
||||
/// carries the same descriptors in its reply bag. Empty when no bridge is
|
||||
/// installed or the bridge failed, so a probe still answers.
|
||||
pub async fn advertised_workflows() -> Vec<WorkflowDescriptor> {
|
||||
advertised_workflows_for(bridge_generation().bridge).await
|
||||
}
|
||||
|
||||
pub(super) async fn advertised_workflows_for(
|
||||
bridge: Option<Arc<dyn WorkflowBridge>>,
|
||||
) -> Vec<WorkflowDescriptor> {
|
||||
let Some(bridge) = bridge else {
|
||||
return Vec::new();
|
||||
};
|
||||
read_batch(bridge)
|
||||
.await
|
||||
.map(|batch| batch.workflows)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read one advert batch off the bridge on a blocking thread. An error or panic
|
||||
/// remains a failed read — never an empty replacement or a partial batch.
|
||||
async fn read_batch(bridge: Arc<dyn WorkflowBridge>) -> Result<RegisterWorkflows, String> {
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
Ok(RegisterWorkflows {
|
||||
workflows: bridge.try_list()?,
|
||||
agent_id: bridge.agent_id(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(batch) => batch,
|
||||
Err(err) => Err(format!("the workflow store failed to list: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_batch(batch: RegisterWorkflows) -> bool {
|
||||
super::emit_awaited(EVENT_REGISTER_WORKFLOWS, batch).await
|
||||
}
|
||||
|
||||
/// Handle an inbound `medulla:workflow_request`.
|
||||
///
|
||||
/// Returns immediately: the work runs on its own task so a `copilot` turn (up to
|
||||
/// ten minutes of agent time) cannot hold the socket read loop, the same shape
|
||||
/// `medulla:task_run` and `orch:tool_call` already use.
|
||||
pub fn handle_workflow_request(request: WorkflowRequest) {
|
||||
let BridgeGeneration { bridge, cancel } = bridge_generation();
|
||||
let connection_cancel = connection_generation();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => {
|
||||
log::debug!("[medulla] discarded workflow request from an old bridge");
|
||||
}
|
||||
_ = connection_cancel.cancelled() => {
|
||||
log::debug!("[medulla] discarded workflow request from a closed socket");
|
||||
}
|
||||
_ = async move {
|
||||
let request_id = request.request_id.clone();
|
||||
let op = request.op;
|
||||
let outcome = match bridge {
|
||||
Some(bridge) => dispatch(bridge, request).await,
|
||||
None => Err("this agent has no workflow store installed".to_string()),
|
||||
};
|
||||
match &outcome {
|
||||
Ok(_) => log::debug!("[medulla] workflow {op:?} request_id={request_id} ok"),
|
||||
Err(err) => {
|
||||
log::warn!("[medulla] workflow {op:?} request_id={request_id} failed: {err}")
|
||||
}
|
||||
}
|
||||
super::emit_awaited(
|
||||
EVENT_WORKFLOW_RESULT,
|
||||
result_frame(request_id, outcome),
|
||||
)
|
||||
.await;
|
||||
} => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Answer a `medulla:workflow_request` this build could not decode.
|
||||
///
|
||||
/// A frame with an unknown `op` (a newer backend) or a missing field would
|
||||
/// otherwise be dropped, and a dropped request costs the backend the op's whole
|
||||
/// deadline. The `requestId` is recovered from the raw JSON — without one there
|
||||
/// is nothing to correlate, so the frame really is unanswerable and is only
|
||||
/// logged.
|
||||
pub fn reject_unparsed_request(raw: &Value, reason: &str) {
|
||||
let Some(request_id) = raw.get("requestId").and_then(Value::as_str) else {
|
||||
log::warn!("[medulla] undecodable workflow_request carries no requestId — cannot answer");
|
||||
return;
|
||||
};
|
||||
super::emit(
|
||||
EVENT_WORKFLOW_RESULT,
|
||||
result_frame(
|
||||
request_id.to_string(),
|
||||
Err(format!("this agent could not read the request: {reason}")),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Project a dispatch outcome onto the wire frame. Split out so the mapping
|
||||
/// (which arm is populated, and that exactly one of them is) is directly
|
||||
/// testable without a socket or a runtime.
|
||||
fn result_frame(request_id: String, outcome: Result<Value, String>) -> WorkflowResult {
|
||||
match outcome {
|
||||
Ok(data) => WorkflowResult {
|
||||
request_id,
|
||||
ok: true,
|
||||
data: Some(data),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => WorkflowResult {
|
||||
request_id,
|
||||
ok: false,
|
||||
data: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one request to `bridge`. Every failure mode — a missing op argument, a
|
||||
/// bridge error, a panicking bridge method — comes back as an `Err(String)` the
|
||||
/// caller turns into `ok: false`.
|
||||
///
|
||||
/// The bridge is a parameter rather than read from the registry here so the
|
||||
/// whole dispatch table is exercisable against a scripted bridge without
|
||||
/// touching process-global state.
|
||||
async fn dispatch(
|
||||
bridge: Arc<dyn WorkflowBridge>,
|
||||
request: WorkflowRequest,
|
||||
) -> Result<Value, String> {
|
||||
match request.op {
|
||||
WorkflowOp::Get => {
|
||||
let id = require_workflow_id(&request, "get")?;
|
||||
blocking(move || bridge.get(&id)).await
|
||||
}
|
||||
WorkflowOp::NodeKinds => {
|
||||
let kind = request.kind.clone();
|
||||
blocking(move || bridge.node_kinds(kind.as_deref())).await
|
||||
}
|
||||
WorkflowOp::Runs => {
|
||||
let id = require_workflow_id(&request, "runs")?;
|
||||
blocking(move || bridge.runs(&id)).await
|
||||
}
|
||||
WorkflowOp::Copilot => {
|
||||
let instruction = request
|
||||
.instruction
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "workflow copilot requires a non-empty instruction".to_string())?;
|
||||
let turn = bridge.copilot(instruction, request.workflow_id.as_deref());
|
||||
let outcome = catching_panics(turn).await??;
|
||||
serde_json::to_value(outcome)
|
||||
.map_err(|err| format!("failed to serialize the copilot outcome: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `workflowId` an op needs, or a message naming what was missing. Blank is
|
||||
/// treated as absent: an empty id could only ever miss in the host's store, and
|
||||
/// saying so up front beats a "not found" the reader cannot interpret.
|
||||
fn require_workflow_id(request: &WorkflowRequest, op: &str) -> Result<String, String> {
|
||||
request
|
||||
.workflow_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("workflow {op} requires a workflowId"))
|
||||
}
|
||||
|
||||
/// Run a synchronous bridge read on a blocking thread, mapping a panic inside it
|
||||
/// to an error rather than letting it unwind through the task.
|
||||
async fn blocking<F>(read: F) -> Result<Value, String>
|
||||
where
|
||||
F: FnOnce() -> Result<Value, String> + Send + 'static,
|
||||
{
|
||||
match tokio::task::spawn_blocking(read).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => Err(format!("the workflow store failed to answer: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The async twin of [`blocking`]: run a bridge turn that must be awaited on the
|
||||
/// socket runtime — today only `copilot` — and map a panic inside it to an error.
|
||||
///
|
||||
/// `copilot` cannot go on a blocking thread (it is a whole agent turn, not a
|
||||
/// store read), so it does not inherit `spawn_blocking`'s `JoinError` net. Left
|
||||
/// bare, a panicking `copilot` would abort the task that owes the backend a
|
||||
/// `workflow_result` and cost it the op's whole ten-minute deadline. Caught
|
||||
/// in-place rather than on a supervised child task so cancelling the answering
|
||||
/// task still cancels the turn.
|
||||
async fn catching_panics<F, T>(turn: F) -> Result<T, String>
|
||||
where
|
||||
F: Future<Output = T>,
|
||||
{
|
||||
match AssertUnwindSafe(turn).catch_unwind().await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(panic) => {
|
||||
let reason = panic
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.or_else(|| panic.downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("unknown panic");
|
||||
log::error!("[medulla] workflow copilot panicked: {reason}");
|
||||
Err(format!(
|
||||
"the workflow store failed to answer: the copilot panicked: {reason}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A bridge whose every read is scripted, so the dispatch table can be
|
||||
/// exercised without a store.
|
||||
#[derive(Default)]
|
||||
struct FakeBridge {
|
||||
fail: bool,
|
||||
fail_list: bool,
|
||||
panic_on_get: bool,
|
||||
panic_on_copilot: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkflowBridge for FakeBridge {
|
||||
fn list(&self) -> Vec<WorkflowDescriptor> {
|
||||
vec![WorkflowDescriptor {
|
||||
id: "wf-1".into(),
|
||||
name: "Deploy".into(),
|
||||
description: String::new(),
|
||||
node_count: 3,
|
||||
enabled: None,
|
||||
trigger_kind: None,
|
||||
agent_id: None,
|
||||
workspace_id: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn try_list(&self) -> Result<Vec<WorkflowDescriptor>, String> {
|
||||
if self.fail_list {
|
||||
return Err("store unavailable".into());
|
||||
}
|
||||
Ok(self.list())
|
||||
}
|
||||
|
||||
fn get(&self, id: &str) -> Result<Value, String> {
|
||||
if self.panic_on_get {
|
||||
panic!("store exploded");
|
||||
}
|
||||
if self.fail {
|
||||
return Err("no such workflow".into());
|
||||
}
|
||||
Ok(json!({ "id": id, "graph": [] }))
|
||||
}
|
||||
|
||||
fn node_kinds(&self, kind: Option<&str>) -> Result<Value, String> {
|
||||
Ok(json!({ "kind": kind }))
|
||||
}
|
||||
|
||||
fn runs(&self, id: &str) -> Result<Value, String> {
|
||||
Ok(json!({ "runs": [id] }))
|
||||
}
|
||||
|
||||
async fn copilot(
|
||||
&self,
|
||||
instruction: &str,
|
||||
workflow_id: Option<&str>,
|
||||
) -> Result<CopilotOutcome, String> {
|
||||
if self.panic_on_copilot {
|
||||
panic!("copilot exploded");
|
||||
}
|
||||
if self.fail {
|
||||
return Err("copilot refused".into());
|
||||
}
|
||||
Ok(CopilotOutcome {
|
||||
reply: instruction.to_string(),
|
||||
changes: vec![],
|
||||
created: workflow_id.is_none().then(|| "wf-new".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_id(&self) -> Option<String> {
|
||||
Some("orchestrator".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// A scripted bridge, handed straight to `dispatch` — the tests never touch
|
||||
/// the process-global registry, so they stay order- and thread-independent.
|
||||
fn bridge_of(fail: bool, panic_on_get: bool) -> Arc<dyn WorkflowBridge> {
|
||||
Arc::new(FakeBridge {
|
||||
fail,
|
||||
panic_on_get,
|
||||
..FakeBridge::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// A bridge whose authoring turn panics mid-`await` — the async twin of
|
||||
/// `panic_on_get`, and the one arm `blocking()` does not cover.
|
||||
fn panicking_copilot_bridge() -> Arc<dyn WorkflowBridge> {
|
||||
Arc::new(FakeBridge {
|
||||
panic_on_copilot: true,
|
||||
..FakeBridge::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn request(op: WorkflowOp) -> WorkflowRequest {
|
||||
WorkflowRequest {
|
||||
request_id: "r1".into(),
|
||||
op,
|
||||
workflow_id: None,
|
||||
kind: None,
|
||||
instruction: None,
|
||||
agent_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_frame_populates_exactly_one_arm() {
|
||||
let ok = result_frame("r1".into(), Ok(json!({ "a": 1 })));
|
||||
assert!(ok.ok);
|
||||
assert_eq!(ok.data, Some(json!({ "a": 1 })));
|
||||
assert!(ok.error.is_none());
|
||||
|
||||
let failed = result_frame("r1".into(), Err("boom".into()));
|
||||
assert!(!failed.ok);
|
||||
assert!(failed.data.is_none());
|
||||
assert_eq!(failed.error.as_deref(), Some("boom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_generation_cancels_work_at_each_lifetime_boundary() {
|
||||
let mut generation = ConnectionGeneration::disconnected();
|
||||
assert!(generation.snapshot().is_cancelled());
|
||||
|
||||
generation.begin();
|
||||
let connected = generation.snapshot();
|
||||
assert!(!connected.is_cancelled());
|
||||
|
||||
generation.end();
|
||||
assert!(connected.is_cancelled());
|
||||
assert!(generation.snapshot().is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_workflow_id_is_reported_not_guessed() {
|
||||
let mut req = request(WorkflowOp::Get);
|
||||
assert_eq!(
|
||||
require_workflow_id(&req, "get"),
|
||||
Err("workflow get requires a workflowId".to_string())
|
||||
);
|
||||
// Blank is absent, not an id that will merely miss in the store.
|
||||
req.workflow_id = Some(" ".into());
|
||||
assert!(require_workflow_id(&req, "get").is_err());
|
||||
req.workflow_id = Some(" wf-1 ".into());
|
||||
assert_eq!(require_workflow_id(&req, "get"), Ok("wf-1".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_routes_each_read_to_the_bridge() {
|
||||
let bridge = bridge_of(false, false);
|
||||
let mut get = request(WorkflowOp::Get);
|
||||
get.workflow_id = Some("wf-1".into());
|
||||
assert_eq!(
|
||||
dispatch(Arc::clone(&bridge), get).await.unwrap(),
|
||||
json!({ "id": "wf-1", "graph": [] })
|
||||
);
|
||||
|
||||
let mut kinds = request(WorkflowOp::NodeKinds);
|
||||
kinds.kind = Some("agent".into());
|
||||
assert_eq!(
|
||||
dispatch(Arc::clone(&bridge), kinds).await.unwrap(),
|
||||
json!({ "kind": "agent" })
|
||||
);
|
||||
|
||||
let mut runs = request(WorkflowOp::Runs);
|
||||
runs.workflow_id = Some("wf-1".into());
|
||||
assert_eq!(
|
||||
dispatch(bridge, runs).await.unwrap(),
|
||||
json!({ "runs": ["wf-1"] })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_read_missing_its_workflow_id_fails_without_reaching_the_store() {
|
||||
let outcome = dispatch(bridge_of(false, true), request(WorkflowOp::Get)).await;
|
||||
// `panic_on_get` proves the store was never consulted.
|
||||
assert_eq!(
|
||||
outcome,
|
||||
Err("workflow get requires a workflowId".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copilot_requires_an_instruction_and_reports_creation() {
|
||||
let bridge = bridge_of(false, false);
|
||||
// A blank instruction never reaches the host's agent.
|
||||
let mut blank = request(WorkflowOp::Copilot);
|
||||
blank.instruction = Some(" ".into());
|
||||
assert!(dispatch(Arc::clone(&bridge), blank).await.is_err());
|
||||
|
||||
let mut create = request(WorkflowOp::Copilot);
|
||||
create.instruction = Some("build a deploy flow".into());
|
||||
let data = dispatch(bridge, create).await.unwrap();
|
||||
assert_eq!(data["reply"], "build a deploy flow");
|
||||
assert_eq!(data["created"], "wf-new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bridge_error_becomes_a_readable_failure() {
|
||||
let bridge = bridge_of(true, false);
|
||||
let mut get = request(WorkflowOp::Get);
|
||||
get.workflow_id = Some("wf-1".into());
|
||||
assert_eq!(
|
||||
dispatch(Arc::clone(&bridge), get).await,
|
||||
Err("no such workflow".to_string())
|
||||
);
|
||||
|
||||
let mut copilot = request(WorkflowOp::Copilot);
|
||||
copilot.instruction = Some("change it".into());
|
||||
assert_eq!(
|
||||
dispatch(bridge, copilot).await,
|
||||
Err("copilot refused".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_list_error_is_not_converted_to_an_empty_registration() {
|
||||
let bridge: Arc<dyn WorkflowBridge> = Arc::new(FakeBridge {
|
||||
fail_list: true,
|
||||
..FakeBridge::default()
|
||||
});
|
||||
assert_eq!(
|
||||
read_batch(bridge).await.unwrap_err(),
|
||||
"store unavailable".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_newer_registration_suppresses_an_older_snapshot() {
|
||||
let sequencer = RegistrationSequencer::new();
|
||||
let older = sequencer.begin();
|
||||
let newer = sequencer.begin();
|
||||
let emitted = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
assert_eq!(
|
||||
sequencer
|
||||
.emit_if_newer(newer, || async {
|
||||
emitted.lock().unwrap().push("newer");
|
||||
true
|
||||
})
|
||||
.await,
|
||||
SequencedEmit::Emitted
|
||||
);
|
||||
assert_eq!(
|
||||
sequencer
|
||||
.emit_if_newer(older, || async {
|
||||
emitted.lock().unwrap().push("older");
|
||||
true
|
||||
})
|
||||
.await,
|
||||
SequencedEmit::Superseded
|
||||
);
|
||||
assert_eq!(*emitted.lock().unwrap(), vec!["newer"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_newer_read_does_not_suppress_an_older_success() {
|
||||
let sequencer = RegistrationSequencer::new();
|
||||
let older = sequencer.begin();
|
||||
let _newer = sequencer.begin();
|
||||
let emitted = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
// The newer read failed, so it never calls `emit_if_newer`. The older
|
||||
// successful snapshot must still advance the backend from its
|
||||
// pre-mutation state.
|
||||
assert_eq!(
|
||||
sequencer
|
||||
.emit_if_newer(older, || async {
|
||||
emitted.lock().unwrap().push("older");
|
||||
true
|
||||
})
|
||||
.await,
|
||||
SequencedEmit::Emitted
|
||||
);
|
||||
assert_eq!(*emitted.lock().unwrap(), vec!["older"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_enqueue_does_not_advance_the_success_watermark() {
|
||||
let sequencer = RegistrationSequencer::new();
|
||||
let older = sequencer.begin();
|
||||
let newer = sequencer.begin();
|
||||
|
||||
assert_eq!(
|
||||
sequencer.emit_if_newer(newer, || async { false }).await,
|
||||
SequencedEmit::Failed
|
||||
);
|
||||
assert_eq!(
|
||||
sequencer.emit_if_newer(older, || async { true }).await,
|
||||
SequencedEmit::Emitted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_panicking_bridge_still_answers() {
|
||||
let mut get = request(WorkflowOp::Get);
|
||||
get.workflow_id = Some("wf-1".into());
|
||||
let outcome = dispatch(bridge_of(false, true), get).await;
|
||||
// The request must not be dropped — a dropped one costs the server its
|
||||
// whole deadline.
|
||||
assert!(outcome
|
||||
.unwrap_err()
|
||||
.starts_with("the workflow store failed to answer"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_panicking_copilot_still_answers() {
|
||||
let mut copilot = request(WorkflowOp::Copilot);
|
||||
copilot.instruction = Some("build a deploy flow".into());
|
||||
let outcome = dispatch(panicking_copilot_bridge(), copilot).await;
|
||||
// A panic inside the authoring turn must come back as an answer, not
|
||||
// abort the task that owes the backend a frame: an unanswered copilot
|
||||
// costs the server its whole ten-minute deadline.
|
||||
assert!(outcome
|
||||
.clone()
|
||||
.unwrap_err()
|
||||
.starts_with("the workflow store failed to answer"));
|
||||
let frame = result_frame("r1".into(), outcome);
|
||||
assert!(!frame.ok);
|
||||
assert!(frame.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undecodable_frame_without_a_request_id_is_only_logged() {
|
||||
// Nothing to correlate, so nothing can be answered — but it must not
|
||||
// panic on the socket read path either.
|
||||
reject_unparsed_request(&json!({ "op": "apply_ops" }), "unknown variant");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
mod event_handlers;
|
||||
pub mod manager;
|
||||
pub mod medulla;
|
||||
mod ops;
|
||||
mod schemas;
|
||||
pub(crate) mod token_provider;
|
||||
pub mod types;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
use crate::api::models::socket::SocketState;
|
||||
|
||||
use super::SocketManager;
|
||||
|
||||
async fn connect_static_using(
|
||||
manager: &SocketManager,
|
||||
url: &str,
|
||||
token: &str,
|
||||
clear_workflows: impl FnOnce(),
|
||||
) -> Result<SocketState, String> {
|
||||
let _rebind = manager.lock_identity_rebind().await;
|
||||
manager.disconnect().await?;
|
||||
clear_workflows();
|
||||
manager.connect(url, token).await?;
|
||||
Ok(manager.get_state())
|
||||
}
|
||||
|
||||
pub async fn connect_static(
|
||||
manager: &SocketManager,
|
||||
url: &str,
|
||||
token: &str,
|
||||
) -> Result<SocketState, String> {
|
||||
log::info!("[socket:rpc] connect — disabling identity-bound workflow plane");
|
||||
connect_static_using(
|
||||
manager,
|
||||
url,
|
||||
token,
|
||||
super::medulla::workflows::clear_workflow_bridge,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn disconnect(manager: &SocketManager) -> Result<SocketState, String> {
|
||||
log::info!("[socket:rpc] disconnect");
|
||||
let _rebind = manager.lock_identity_rebind().await;
|
||||
manager.disconnect().await?;
|
||||
Ok(manager.get_state())
|
||||
}
|
||||
|
||||
pub async fn connect_with_session(manager: &SocketManager) -> Result<SocketState, String> {
|
||||
log::info!("[socket:rpc] connect_with_session — resolving credentials");
|
||||
let config =
|
||||
std::sync::Arc::new(crate::openhuman::config::rpc::load_config_with_timeout().await?);
|
||||
let api_url = crate::api::config::effective_backend_api_url(&config.api_url);
|
||||
crate::api::jwt::get_session_token(&config)
|
||||
.map_err(|e| format!("failed to read session token: {e}"))?
|
||||
.ok_or("no session token stored — user must log in first")?;
|
||||
|
||||
let _rebind = manager.lock_identity_rebind().await;
|
||||
manager.disconnect().await?;
|
||||
#[cfg(feature = "flows")]
|
||||
if crate::core::runtime::context::CoreContext::current()
|
||||
.is_some_and(|context| context.domains().flows)
|
||||
{
|
||||
crate::openhuman::flows::medulla_bridge::install(std::sync::Arc::clone(&config));
|
||||
}
|
||||
let provider = super::token_provider::token_provider_from_config(config);
|
||||
manager.connect_with_provider(&api_url, provider).await?;
|
||||
Ok(manager.get_state())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_token_connection_clears_identity_state_after_disconnect() {
|
||||
let manager = SocketManager::new();
|
||||
manager
|
||||
.connect("http://127.0.0.1:1", "opaque-token")
|
||||
.await
|
||||
.unwrap();
|
||||
let cleared = AtomicBool::new(false);
|
||||
connect_static_using(&manager, "http://127.0.0.1:1", "replacement", || {
|
||||
assert_eq!(
|
||||
manager.get_state().status,
|
||||
crate::openhuman::socket::types::ConnectionStatus::Disconnected
|
||||
);
|
||||
cleared.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cleared.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
@@ -171,10 +171,7 @@ fn handle_connect(params: Map<String, Value>) -> ControllerFuture {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing required param 'token'")?;
|
||||
|
||||
log::info!("[socket:rpc] connect url={}", url);
|
||||
mgr.connect(url, token).await?;
|
||||
|
||||
let state = mgr.get_state();
|
||||
let state = super::ops::connect_static(mgr, url, token).await?;
|
||||
Ok(json!({ "status": format!("{:?}", state.status) }))
|
||||
})
|
||||
}
|
||||
@@ -182,10 +179,7 @@ fn handle_connect(params: Map<String, Value>) -> ControllerFuture {
|
||||
fn handle_disconnect(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let mgr = require_manager()?;
|
||||
log::info!("[socket:rpc] disconnect");
|
||||
mgr.disconnect().await?;
|
||||
|
||||
let state = mgr.get_state();
|
||||
let state = super::ops::disconnect(mgr).await?;
|
||||
Ok(json!({ "status": format!("{:?}", state.status) }))
|
||||
})
|
||||
}
|
||||
@@ -217,39 +211,7 @@ fn handle_emit(params: Map<String, Value>) -> ControllerFuture {
|
||||
fn handle_connect_with_session(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let mgr = require_manager()?;
|
||||
|
||||
log::info!("[socket:rpc] connect_with_session — resolving credentials");
|
||||
|
||||
// Load config once to derive the API URL and perform the initial
|
||||
// token validation. The config is then moved into a live-refresh
|
||||
// `TokenProvider` so the reconnect loop can re-read the latest
|
||||
// session token from the profile store on every subsequent attempt
|
||||
// (fix for TAURI-RUST-9C / #2892 — stale-token retry storm).
|
||||
let config =
|
||||
std::sync::Arc::new(crate::openhuman::config::rpc::load_config_with_timeout().await?);
|
||||
let api_url = crate::api::config::effective_backend_api_url(&config.api_url);
|
||||
|
||||
// Perform an eager check so the RPC caller gets an immediate error
|
||||
// if no session is stored — the provider will do the same check on
|
||||
// every reconnect attempt inside `ws_loop`.
|
||||
let initial_token = crate::api::jwt::get_session_token(&config)
|
||||
.map_err(|e| format!("failed to read session token: {e}"))?
|
||||
.ok_or("no session token stored — user must log in first")?;
|
||||
|
||||
log::info!(
|
||||
"[socket:rpc] connect_with_session url={} token_len={}",
|
||||
api_url,
|
||||
initial_token.len()
|
||||
);
|
||||
|
||||
// Build a live-refresh provider: on every reconnect attempt the loop
|
||||
// calls this closure to obtain the most recently stored token, picking
|
||||
// up any rotation or re-login that happened while sleeping.
|
||||
let provider = super::token_provider::token_provider_from_config(config);
|
||||
|
||||
mgr.connect_with_provider(&api_url, provider).await?;
|
||||
|
||||
let state = mgr.get_state();
|
||||
let state = super::ops::connect_with_session(mgr).await?;
|
||||
Ok(json!({ "status": format!("{:?}", state.status) }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub(super) fn static_token_provider(token: String) -> TokenProvider {
|
||||
/// This is the **live-refresh** path used by `handle_connect_with_session`:
|
||||
/// when the loop retries after a disconnect it will see any token that was
|
||||
/// refreshed or re-stored since the previous attempt.
|
||||
pub(super) fn token_provider_from_config(
|
||||
pub(crate) fn token_provider_from_config(
|
||||
config: Arc<crate::openhuman::config::Config>,
|
||||
) -> TokenProvider {
|
||||
Arc::new(move || {
|
||||
|
||||
@@ -163,6 +163,7 @@ pub(super) async fn ws_loop(
|
||||
// session-expired escalation below — not just explicit
|
||||
// `SocketManager::disconnect()` (CodeRabbit #4355).
|
||||
shared.ack_registry.cancel_all();
|
||||
super::medulla::workflows::end_connection_generation();
|
||||
|
||||
match outcome {
|
||||
ConnectionOutcome::Shutdown => {
|
||||
@@ -720,6 +721,7 @@ fn handle_sio_packet(
|
||||
b'1' => {
|
||||
// Socket.IO DISCONNECT
|
||||
log::info!("[socket] SIO DISCONNECT from server");
|
||||
super::medulla::workflows::end_connection_generation();
|
||||
*shared.status.write() = ConnectionStatus::Disconnected;
|
||||
*shared.socket_id.write() = None;
|
||||
emit_state_change(shared);
|
||||
|
||||
Reference in New Issue
Block a user