mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
bbe997fb86
commit
307c8e2b2a
@@ -352,6 +352,52 @@ pub enum DomainEvent {
|
||||
decision: String,
|
||||
},
|
||||
|
||||
// ── Artifacts ───────────────────────────────────────────────────────
|
||||
/// An artifact transitioned to [`ArtifactStatus::Ready`] — file
|
||||
/// is on disk and ready to be downloaded. Published by
|
||||
/// [`crate::openhuman::artifacts::store::finalize_artifact`].
|
||||
/// Bridged to the web channel as an `artifact_ready` socket event
|
||||
/// when the publishing turn carries an `APPROVAL_CHAT_CONTEXT`
|
||||
/// (see [`crate::openhuman::approval::ApprovalChatContext`]).
|
||||
/// Sub-task #2779 of #1535.
|
||||
ArtifactReady {
|
||||
/// UUID of the artifact record.
|
||||
artifact_id: String,
|
||||
/// Lowercase variant of `ArtifactKind` (`presentation`,
|
||||
/// `document`, `image`, `other`).
|
||||
kind: String,
|
||||
/// Human-readable title (also the on-disk filename stem).
|
||||
title: String,
|
||||
/// Relative path under `<workspace>/artifacts/`, e.g.
|
||||
/// `"<uuid>/deck.pptx"`. The absolute path is reachable via
|
||||
/// `ai_get_artifact` so the renderer never needs the
|
||||
/// workspace root.
|
||||
path: String,
|
||||
/// Final on-disk file size in bytes.
|
||||
size_bytes: u64,
|
||||
/// Chat thread the artifact belongs to, when the producing
|
||||
/// turn carried an `APPROVAL_CHAT_CONTEXT`. `None` for CLI /
|
||||
/// cron / sub-agent paths — no client to fan out to.
|
||||
thread_id: Option<String>,
|
||||
/// Socket.IO client id (room) to surface the card to, when
|
||||
/// known. `None` for non-chat callers.
|
||||
client_id: Option<String>,
|
||||
},
|
||||
/// An artifact transitioned to [`ArtifactStatus::Failed`] — the
|
||||
/// producer surfaced a reason and the UI should render a
|
||||
/// retry-hint card instead of a download. Bridged the same way
|
||||
/// as [`Self::ArtifactReady`]. Sub-task #2779 of #1535.
|
||||
ArtifactFailed {
|
||||
artifact_id: String,
|
||||
kind: String,
|
||||
title: String,
|
||||
/// Producer-supplied failure reason. Already truncated by the
|
||||
/// producer (e.g. `PresentationError::truncate_stderr`).
|
||||
error: String,
|
||||
thread_id: Option<String>,
|
||||
client_id: Option<String>,
|
||||
},
|
||||
|
||||
// ── Webhooks ────────────────────────────────────────────────────────
|
||||
/// An incoming webhook request from the transport layer, ready for routing.
|
||||
WebhookIncomingRequest {
|
||||
@@ -846,6 +892,8 @@ impl DomainEvent {
|
||||
|
||||
Self::ApprovalRequested { .. } | Self::ApprovalDecided { .. } => "approval",
|
||||
|
||||
Self::ArtifactReady { .. } | Self::ArtifactFailed { .. } => "artifact",
|
||||
|
||||
Self::McpServerInstalled { .. }
|
||||
| Self::McpServerConnected { .. }
|
||||
| Self::McpServerDisconnected { .. }
|
||||
@@ -934,6 +982,8 @@ impl DomainEvent {
|
||||
Self::SessionExpired { .. } => "SessionExpired",
|
||||
Self::ApprovalRequested { .. } => "ApprovalRequested",
|
||||
Self::ApprovalDecided { .. } => "ApprovalDecided",
|
||||
Self::ArtifactReady { .. } => "ArtifactReady",
|
||||
Self::ArtifactFailed { .. } => "ArtifactFailed",
|
||||
Self::McpServerInstalled { .. } => "McpServerInstalled",
|
||||
Self::McpServerConnected { .. } => "McpServerConnected",
|
||||
Self::McpServerDisconnected { .. } => "McpServerDisconnected",
|
||||
|
||||
@@ -2081,6 +2081,13 @@ pub async fn bootstrap_core_runtime(embedded_core: bool) {
|
||||
Prompt-class external-effect tool calls run unprompted"
|
||||
);
|
||||
}
|
||||
// Artifact surface bridges DomainEvent::ArtifactReady/Failed onto the web
|
||||
// channel ("Files in this chat" panel + ArtifactCard updates). This is
|
||||
// independent of the approval-gate config — keep it outside the
|
||||
// `if approval_gate` block so artifact events still publish when the user
|
||||
// sets OPENHUMAN_APPROVAL_GATE=0 (CR #3328947323 on PR #3026). Idempotent
|
||||
// (OnceLock-guarded inside register_artifact_surface_subscriber).
|
||||
crate::openhuman::channels::providers::web::register_artifact_surface_subscriber();
|
||||
|
||||
// --- Workspace migrations --------------------------------------------
|
||||
crate::openhuman::startup::run_workspace_migrations(&workspace_dir);
|
||||
|
||||
@@ -160,7 +160,6 @@ named = [
|
||||
# Both are routed through the same security/approval gate as `shell`.
|
||||
"workflow_load",
|
||||
"workflow_phase",
|
||||
# Presentation generation (#2778). Synthesises a .pptx from a
|
||||
# structured slide spec via a native Rust engine (`ppt-rs`) running
|
||||
# in-process — no Python subprocess, no managed venv. Output lands
|
||||
# in the workspace artifacts directory and the tool returns the
|
||||
|
||||
@@ -348,13 +348,27 @@ pub async fn create_artifact(
|
||||
/// Flip a pending artifact to [`ArtifactStatus::Ready`] and persist
|
||||
/// the final size. Idempotent on already-ready artifacts (no-op + log).
|
||||
/// Returns the updated metadata.
|
||||
///
|
||||
/// On a real transition (Pending → Ready), publishes
|
||||
/// [`DomainEvent::ArtifactReady`] on the global bus so the web
|
||||
/// channel can surface a download card to the originating thread.
|
||||
/// When the calling task carries no
|
||||
/// [`ApprovalChatContext`](crate::openhuman::approval::ApprovalChatContext)
|
||||
/// (CLI / cron / sub-agent paths), the event is still published but
|
||||
/// `thread_id` / `client_id` are `None` so the socket bridge silently
|
||||
/// drops it. Idempotent calls (already-Ready) skip the publish so we
|
||||
/// don't flap the UI.
|
||||
pub async fn finalize_artifact(
|
||||
workspace_dir: &Path,
|
||||
artifact_id: &str,
|
||||
size_bytes: u64,
|
||||
) -> Result<ArtifactMeta, String> {
|
||||
let mut meta = get_artifact(workspace_dir, artifact_id).await?;
|
||||
if matches!(meta.status, ArtifactStatus::Ready) && meta.size_bytes == size_bytes {
|
||||
if matches!(meta.status, ArtifactStatus::Ready) {
|
||||
// Idempotent on status alone: a second finalize with a different
|
||||
// size_bytes shouldn't re-emit ArtifactReady and flap the UI card
|
||||
// — once an artifact is Ready, callers should not be redefining
|
||||
// its size. Per graycyrus on PR #3017.
|
||||
log::debug!("[artifacts] finalize_artifact: id={artifact_id} already Ready, no-op");
|
||||
return Ok(meta);
|
||||
}
|
||||
@@ -363,6 +377,17 @@ pub async fn finalize_artifact(
|
||||
meta.error = None;
|
||||
save_artifact_meta(workspace_dir, &meta).await?;
|
||||
log::debug!("[artifacts] finalize_artifact: id={artifact_id} -> Ready size={size_bytes}");
|
||||
|
||||
let (thread_id, client_id) = current_chat_context();
|
||||
crate::core::event_bus::publish_global(crate::core::event_bus::DomainEvent::ArtifactReady {
|
||||
artifact_id: meta.id.clone(),
|
||||
kind: meta.kind.as_str().to_string(),
|
||||
title: meta.title.clone(),
|
||||
path: meta.path.clone(),
|
||||
size_bytes: meta.size_bytes,
|
||||
thread_id,
|
||||
client_id,
|
||||
});
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
@@ -370,6 +395,10 @@ pub async fn finalize_artifact(
|
||||
/// failure reason. The producer should call this when generation
|
||||
/// fails so the UI / RPC consumer can surface a useful message
|
||||
/// instead of an indefinite spinner. Returns the updated metadata.
|
||||
///
|
||||
/// Publishes [`DomainEvent::ArtifactFailed`] so the chat surface
|
||||
/// flips the in-flight card to a retry-hint state. Same chat-context
|
||||
/// rules as [`finalize_artifact`].
|
||||
pub async fn fail_artifact(
|
||||
workspace_dir: &Path,
|
||||
artifact_id: &str,
|
||||
@@ -379,10 +408,39 @@ pub async fn fail_artifact(
|
||||
meta.status = ArtifactStatus::Failed;
|
||||
meta.error = Some(reason.to_string());
|
||||
save_artifact_meta(workspace_dir, &meta).await?;
|
||||
log::warn!("[artifacts] fail_artifact: id={artifact_id} -> Failed reason={reason:?}");
|
||||
// Log only the size of the reason — it can carry provider stderr
|
||||
// / user-derived content, which we don't want flushed verbatim
|
||||
// into structured logs. The full payload is still persisted on
|
||||
// `meta.error` for the UI surface and the chat event below.
|
||||
log::warn!(
|
||||
"[artifacts] fail_artifact: id={artifact_id} -> Failed reason_len={}",
|
||||
reason.len()
|
||||
);
|
||||
|
||||
let (thread_id, client_id) = current_chat_context();
|
||||
crate::core::event_bus::publish_global(crate::core::event_bus::DomainEvent::ArtifactFailed {
|
||||
artifact_id: meta.id.clone(),
|
||||
kind: meta.kind.as_str().to_string(),
|
||||
title: meta.title.clone(),
|
||||
error: reason.to_string(),
|
||||
thread_id,
|
||||
client_id,
|
||||
});
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// Read the active [`ApprovalChatContext`] task-local (set by
|
||||
/// `channels::providers::web` around each chat turn) and return its
|
||||
/// thread + client ids. Returns `(None, None)` for non-chat callers
|
||||
/// (CLI, cron, sub-agent runners) so artifact emit hooks degrade
|
||||
/// gracefully — the event is still published but the web subscriber
|
||||
/// drops it for lack of a routing target.
|
||||
fn current_chat_context() -> (Option<String>, Option<String>) {
|
||||
crate::openhuman::approval::APPROVAL_CHAT_CONTEXT
|
||||
.try_with(|ctx| (Some(ctx.thread_id.clone()), Some(ctx.client_id.clone())))
|
||||
.unwrap_or((None, None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "store_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -62,6 +62,120 @@ pub fn register_approval_surface_subscriber() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle for the artifact-surface subscriber. Set once on
|
||||
/// [`register_artifact_surface_subscriber`]; subsequent calls no-op.
|
||||
static ARTIFACT_SURFACE_HANDLE: OnceLock<SubscriptionHandle> = OnceLock::new();
|
||||
|
||||
/// Bridge artifact lifecycle events onto the web channel.
|
||||
/// `DomainEvent::ArtifactReady` / `ArtifactFailed` (published by
|
||||
/// `artifacts::store::{finalize,fail}_artifact` from #2778's producer
|
||||
/// surface) carry the thread_id + client_id when the producing turn
|
||||
/// ran under an `APPROVAL_CHAT_CONTEXT`. When present, fan out as an
|
||||
/// `artifact_ready` / `artifact_failed` socket event so the frontend
|
||||
/// `chatRuntimeSlice` can upsert the snapshot and the `ArtifactCard`
|
||||
/// can render in the message timeline. Sub-task #2779 of #1535.
|
||||
/// Idempotent. No-op for non-chat events (thread/client id absent).
|
||||
pub fn register_artifact_surface_subscriber() {
|
||||
if ARTIFACT_SURFACE_HANDLE.get().is_some() {
|
||||
return;
|
||||
}
|
||||
match crate::core::event_bus::subscribe_global(Arc::new(ArtifactSurfaceSubscriber)) {
|
||||
Some(handle) => {
|
||||
let _ = ARTIFACT_SURFACE_HANDLE.set(handle);
|
||||
log::info!(
|
||||
"[web-channel] artifact-surface subscriber registered (domain=artifact) — will bridge ArtifactReady/Failed → artifact_ready/artifact_failed socket events"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"[web-channel] failed to register artifact-surface subscriber — bus not initialized"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtifactSurfaceSubscriber;
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for ArtifactSurfaceSubscriber {
|
||||
fn name(&self) -> &str {
|
||||
"channels::web::artifact_surface"
|
||||
}
|
||||
|
||||
fn domains(&self) -> Option<&[&str]> {
|
||||
Some(&["artifact"])
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) {
|
||||
match event {
|
||||
DomainEvent::ArtifactReady {
|
||||
artifact_id,
|
||||
kind,
|
||||
title,
|
||||
path,
|
||||
size_bytes,
|
||||
thread_id,
|
||||
client_id,
|
||||
} => {
|
||||
let (Some(thread_id), Some(client_id)) = (thread_id, client_id) else {
|
||||
log::debug!(
|
||||
"[web-channel] artifact-surface skip ArtifactReady id={artifact_id}: no chat context"
|
||||
);
|
||||
return;
|
||||
};
|
||||
log::info!(
|
||||
"[web-channel] artifact-surface emitting artifact_ready id={artifact_id} kind={kind} thread_id={thread_id} client_id={client_id}"
|
||||
);
|
||||
publish_web_channel_event(WebChannelEvent {
|
||||
event: "artifact_ready".to_string(),
|
||||
client_id: client_id.clone(),
|
||||
thread_id: thread_id.clone(),
|
||||
args: Some(serde_json::json!({
|
||||
"artifact_id": artifact_id,
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"path": path,
|
||||
"size_bytes": size_bytes,
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
DomainEvent::ArtifactFailed {
|
||||
artifact_id,
|
||||
kind,
|
||||
title,
|
||||
error,
|
||||
thread_id,
|
||||
client_id,
|
||||
} => {
|
||||
let (Some(thread_id), Some(client_id)) = (thread_id, client_id) else {
|
||||
log::debug!(
|
||||
"[web-channel] artifact-surface skip ArtifactFailed id={artifact_id}: no chat context"
|
||||
);
|
||||
return;
|
||||
};
|
||||
log::warn!(
|
||||
"[web-channel] artifact-surface emitting artifact_failed id={artifact_id} kind={kind} thread_id={thread_id} client_id={client_id} error_len={}",
|
||||
error.len()
|
||||
);
|
||||
publish_web_channel_event(WebChannelEvent {
|
||||
event: "artifact_failed".to_string(),
|
||||
client_id: client_id.clone(),
|
||||
thread_id: thread_id.clone(),
|
||||
args: Some(serde_json::json!({
|
||||
"artifact_id": artifact_id,
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"error": error,
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalSurfaceSubscriber;
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -56,6 +56,10 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
||||
// Surface parked ApprovalGate requests as chat messages so the user can
|
||||
// answer yes/no in the thread (chat-native approval, issue #1339).
|
||||
crate::openhuman::channels::providers::web::register_approval_surface_subscriber();
|
||||
// Surface generated-artifact lifecycle events (ArtifactReady /
|
||||
// ArtifactFailed) as `artifact_ready` / `artifact_failed` web-channel
|
||||
// events so the frontend ArtifactCard can render in chat (#2779).
|
||||
crate::openhuman::channels::providers::web::register_artifact_surface_subscriber();
|
||||
// Spawn the per-toolkit provider periodic sync scheduler. This is
|
||||
// a thin tokio task that ticks every minute and dispatches into
|
||||
// any provider whose `sync_interval_secs` has elapsed for an
|
||||
|
||||
Reference in New Issue
Block a user