feat(chat): ArtifactCard + Tauri download + backend artifact events (#2779) (#3017)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-06-02 19:32:30 +05:30
committed by GitHub
co-authored by Steven Enamakel
parent bbe997fb86
commit 307c8e2b2a
37 changed files with 2661 additions and 10 deletions
+50
View File
@@ -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",
+7
View File
@@ -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
+60 -2
View File
@@ -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;
+114
View File
@@ -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