Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/ironclaw_product_workflow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ test-support = ["ironclaw_product_adapters/test-support"]

[dependencies]
async-trait = "0.1"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
futures = "0.3"
ironclaw_common = { path = "../ironclaw_common", version = "0.4.1" }
ironclaw_attachments = { path = "../ironclaw_attachments", version = "0.1.0" }
ironclaw_approvals = { path = "../ironclaw_approvals", version = "0.1.0" }
ironclaw_authorization = { path = "../ironclaw_authorization", version = "0.1.0" }
ironclaw_auth = { path = "../ironclaw_auth", version = "0.1.0" }
Expand Down
20 changes: 10 additions & 10 deletions crates/ironclaw_product_workflow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,15 @@ pub use reborn_services::{
AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE, AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE,
AutomationListRequest, AutomationProductFacade, CodexLoginStart,
ConnectableChannelsProductFacade, ExtensionCredentialSetupService,
ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, LlmActiveSelection,
LlmConfigService, LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest,
LlmProbeResult, LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart,
NearAiWalletLoginRequest, NearAiWalletLoginResult, OperatorLogsService,
OperatorServiceLifecycleService, OperatorStatusService, OutboundPreferencesProductFacade,
ProductAgentBoundCaller, RebornAutomationInfo, RebornAutomationRecentRunInfo,
RebornAutomationRecentRunStatus, RebornAutomationRunStatus, RebornAutomationSource,
RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction,
RebornChannelConnectStrategy, RebornConnectableChannelInfo,
ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, InboundAttachmentLander,
LlmActiveSelection, LlmConfigService, LlmConfigServiceError, LlmConfigSnapshot,
LlmModelsResult, LlmProbeRequest, LlmProbeResult, LlmProviderView, NearAiAuthProvider,
NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, NearAiWalletLoginResult,
OperatorLogsService, OperatorServiceLifecycleService, OperatorStatusService,
OutboundPreferencesProductFacade, ProductAgentBoundCaller, RebornAutomationInfo,
RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationRunStatus,
RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse,
RebornChannelConnectAction, RebornChannelConnectStrategy, RebornConnectableChannelInfo,
RebornConnectableChannelListResponse, RebornCreateThreadResponse, RebornDeleteThreadRequest,
RebornDeleteThreadResponse, RebornExtensionActionResponse, RebornExtensionCredentialSetup,
RebornExtensionInfo, RebornExtensionListResponse, RebornExtensionOnboardingPayload,
Expand Down Expand Up @@ -192,7 +192,7 @@ pub use reborn_services::{

pub use webui_inbound::{
WebUiAuthenticatedCaller, WebUiCancelReason, WebUiCancelRunRequest, WebUiCreateThreadRequest,
WebUiGateResolution, WebUiInboundCommand, WebUiInboundValidationCode,
WebUiGateResolution, WebUiInboundAttachment, WebUiInboundCommand, WebUiInboundValidationCode,
WebUiInboundValidationError, WebUiListAutomationsRequest, WebUiListThreadsRequest,
WebUiResolveGateRequest, WebUiSendMessageRequest, WebUiSetupExtensionRequest,
};
Expand Down
64 changes: 60 additions & 4 deletions crates/ironclaw_product_workflow/src/reborn_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::{

use async_trait::async_trait;
use chrono::Utc;
use ironclaw_attachments::InboundAttachment;
use ironclaw_auth::{
AuthProductScope, AuthProviderId, CredentialAccountId, CredentialAccountProjection,
CredentialAccountUpdateBinding, ProviderScope,
Expand All @@ -22,9 +23,10 @@ use ironclaw_product_adapters::{
ProjectionSubscriptionRequest,
};
use ironclaw_threads::{
AcceptInboundMessageRequest, AcceptedInboundMessageReplay, EnsureThreadRequest, MessageContent,
MessageStatus, ReplayAcceptedInboundMessageRequest, SessionThreadError, SessionThreadRecord,
SessionThreadService, ThreadHistory, ThreadHistoryRequest, ThreadMessageId, ThreadScope,
AcceptInboundMessageRequest, AcceptedInboundMessageReplay, AttachmentRef, EnsureThreadRequest,
MessageContent, MessageStatus, ReplayAcceptedInboundMessageRequest, SessionThreadError,
SessionThreadRecord, SessionThreadService, ThreadHistory, ThreadHistoryRequest,
ThreadMessageId, ThreadScope,
};
use ironclaw_turns::{
AcceptedMessageRef, GateRef, GetRunStateRequest, IdempotencyKey, ResumeTurnPrecondition,
Expand Down Expand Up @@ -1271,11 +1273,30 @@ pub trait RebornServicesApi: Send + Sync {
}
}

/// Lands inbound attachment bytes into durable, agent-accessible storage and
/// returns the transcript references to persist on the user message.
///
/// Injected by host composition, which owns the project-scoped filesystem
/// authority. `message_id` is a stable per-message id (the idempotency key)
/// used only to disambiguate the storage path; the implementation writes
/// through the same `MountView` the agent's file tools resolve through, so
/// landed bytes are readable by `file_read`/`list_dir` in later turns.
#[async_trait]
pub trait InboundAttachmentLander: Send + Sync {
async fn land(
&self,
thread_scope: &ThreadScope,
message_id: &str,
attachments: Vec<InboundAttachment>,
) -> Result<Vec<AttachmentRef>, RebornServicesError>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Default facade implementation composed at the WebUI boundary.
#[derive(Clone)]
pub struct RebornServices {
thread_service: Arc<dyn SessionThreadService>,
turn_coordinator: Arc<dyn TurnCoordinator>,
inbound_attachments: Option<Arc<dyn InboundAttachmentLander>>,
event_stream: Option<Arc<dyn ProjectionStream>>,
lifecycle_facade: Arc<dyn LifecycleProductFacade>,
automation_facade: Arc<dyn AutomationProductFacade>,
Expand All @@ -1302,6 +1323,7 @@ impl RebornServices {
Self {
thread_service,
turn_coordinator,
inbound_attachments: None,
event_stream: None,
lifecycle_facade: Arc::new(UnsupportedLifecycleProductFacade::new_static(
"reborn_lifecycle_facade_unwired",
Expand Down Expand Up @@ -1330,6 +1352,17 @@ impl RebornServices {
self
}

/// Wire the port that lands inbound attachment bytes into project storage.
/// Without it, a send-message carrying attachments is rejected rather than
/// silently dropping the files.
pub fn with_inbound_attachments(
mut self,
inbound_attachments: Arc<dyn InboundAttachmentLander>,
) -> Self {
self.inbound_attachments = Some(inbound_attachments);
self
}

pub fn with_llm_config_service(mut self, llm_config: Arc<dyn LlmConfigService>) -> Self {
self.llm_config = Some(llm_config);
self
Expand Down Expand Up @@ -1611,6 +1644,9 @@ impl RebornServicesApi for RebornServices {
caller: WebUiAuthenticatedCaller,
request: WebUiSendMessageRequest,
) -> Result<RebornSubmitTurnResponse, RebornServicesError> {
// Decode + budget inline attachment bytes before the request is
// consumed into the (bytes-free, serializable) command.
let attachments = request.decode_attachments()?;
let command = request.into_command(caller)?;
let WebUiInboundCommand::SendMessage {
scope,
Expand Down Expand Up @@ -1683,6 +1719,26 @@ impl RebornServicesApi for RebornServices {
}
}
} else {
// Land attachment bytes (if any) into project storage before the
// message is accepted, recording each as a transcript reference.
// The stable per-message external_event_id is the path's message
// segment, so a same-day retry re-lands at the same path; the lander
// also partitions by UTC day, so a retry that crosses midnight UTC
// lands under the new day's directory (the earlier bytes are left
// addressable but unreferenced). Idempotency is enforced at message
// acceptance, not by the storage path.
let message_content = if attachments.is_empty() {
MessageContent::text(content.clone())
} else {
let lander = self
.inbound_attachments
.as_ref()
.ok_or_else(|| RebornServicesError::service_unavailable(false))?;
let refs = lander
.land(&thread_scope, &external_event_id, attachments)
.await?;
MessageContent::with_attachments(content.clone(), refs)
};
let accepted = self
.thread_service
.accept_inbound_message(AcceptInboundMessageRequest {
Expand All @@ -1692,7 +1748,7 @@ impl RebornServicesApi for RebornServices {
source_binding_id: Some(source_binding_id.clone()),
reply_target_binding_id: Some(source_binding_id.clone()),
external_event_id: Some(external_event_id),
content: MessageContent::text(content.clone()),
content: message_content,
})
.await
.map_err(map_thread_error)?;
Expand Down
8 changes: 8 additions & 0 deletions crates/ironclaw_product_workflow/src/reborn_services/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ impl RebornServicesError {
Self::from_status(RebornServicesErrorCode::Internal, 500, false)
}

/// Sanitized internal (500) error for host-composition adapters that
/// implement workflow ports from outside this crate. The struct fields are
/// public, but new construction sites should route through one constructor
/// rather than hand-rolling the status/kind pairing.
pub fn internal() -> Self {
Self::from_status(RebornServicesErrorCode::Internal, 500, false)
}

pub(super) fn service_unavailable(retryable: bool) -> Self {
Self::from_status_kind(
RebornServicesErrorCode::Unavailable,
Expand Down
106 changes: 106 additions & 0 deletions crates/ironclaw_product_workflow/src/webui_inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! into canonical Reborn commands without depending on WebUI route handlers,
//! product adapters, protocol auth evidence, WASM, or adapter registries.

use ironclaw_attachments::InboundAttachment;
use ironclaw_host_api::{AgentId, ProjectId, TenantId, ThreadId, UserId};
use ironclaw_turns::{
CancelRunRequest, GateRef, IdempotencyKey, SanitizedCancelReason, TurnActor, TurnRunId,
Expand All @@ -16,6 +17,13 @@ const CLIENT_ACTION_ID_MAX_BYTES: usize = 256;
const USER_MESSAGE_TEXT_MAX_BYTES: usize = 64 * 1024;
const GATE_REF_MAX_BYTES: usize = 256;
const CREDENTIAL_REF_MAX_BYTES: usize = 512;
/// Inline-attachment budgets, mirroring the v1 web gateway: at most
/// `MAX_INLINE_ATTACHMENTS` files, `MAX_INLINE_ATTACHMENT_BYTES` decoded bytes
/// per file, and `MAX_INLINE_TOTAL_ATTACHMENT_BYTES` decoded bytes total.
const MAX_INLINE_ATTACHMENTS: usize = 10;
const MAX_INLINE_ATTACHMENT_BYTES: usize = 5 * 1024 * 1024;
const MAX_INLINE_TOTAL_ATTACHMENT_BYTES: usize = 10 * 1024 * 1024;
const ATTACHMENT_FILENAME_MAX_BYTES: usize = 256;

/// Authenticated WebUI caller after route auth has already completed.
///
Expand Down Expand Up @@ -70,6 +78,20 @@ pub struct WebUiCreateThreadRequest {
pub requested_thread_id: Option<String>,
}

/// One inline attachment in a browser send-message body.
///
/// `data_base64` is the base64-encoded file bytes; `mime_type` is validated
/// against the shared attachment format registry. This is the only place raw
/// upload bytes enter the workflow — they are decoded, budgeted, and landed in
/// storage, never carried on the (serializable) inbound command.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct WebUiInboundAttachment {
pub mime_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
pub data_base64: String,
}

/// Browser body for WebUI send-message mutation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct WebUiSendMessageRequest {
Expand All @@ -79,6 +101,90 @@ pub struct WebUiSendMessageRequest {
pub thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<WebUiInboundAttachment>,
}

impl WebUiSendMessageRequest {
/// Validate and decode the inline attachments into bytes-bearing
/// [`InboundAttachment`]s ready for landing.
///
/// Enforces the per-file / per-message / count budgets and rejects
/// unsupported MIME types (per the shared format registry) and malformed
/// base64 with a stable validation error. Kept separate from
/// [`Self::into_command`] so the serializable command never carries raw
/// bytes.
pub fn decode_attachments(
&self,
) -> Result<Vec<InboundAttachment>, WebUiInboundValidationError> {
use base64::Engine;

if self.attachments.len() > MAX_INLINE_ATTACHMENTS {
return Err(WebUiInboundValidationError::new(
"attachments",
WebUiInboundValidationCode::TooLong,
));
}

let mut decoded = Vec::with_capacity(self.attachments.len());
let mut total_bytes = 0usize;
for (index, attachment) in self.attachments.iter().enumerate() {
let mime = ironclaw_common::normalize_mime_type(&attachment.mime_type);
if !ironclaw_common::is_supported_mime(&mime) {
return Err(WebUiInboundValidationError::new(
"attachments.mime_type",
WebUiInboundValidationCode::InvalidValue,
));
}

let bytes = base64::engine::general_purpose::STANDARD
.decode(attachment.data_base64.as_bytes())
.map_err(|_| {
WebUiInboundValidationError::new(
"attachments.data_base64",
WebUiInboundValidationCode::InvalidValue,
)
})?;
if bytes.len() > MAX_INLINE_ATTACHMENT_BYTES {
return Err(WebUiInboundValidationError::new(
"attachments",
WebUiInboundValidationCode::TooLong,
));
}
total_bytes = total_bytes.saturating_add(bytes.len());
if total_bytes > MAX_INLINE_TOTAL_ATTACHMENT_BYTES {
return Err(WebUiInboundValidationError::new(
"attachments",
WebUiInboundValidationCode::TooLong,
));
}

let filename = attachment
.filename
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty());
if let Some(name) = filename
&& name.len() > ATTACHMENT_FILENAME_MAX_BYTES
{
return Err(WebUiInboundValidationError::new(
"attachments.filename",
WebUiInboundValidationCode::TooLong,
));
}

// `kind` and the fallback filename extension are derived from
// `mime_type` inside the landing bridge, so the DTO carries only the
// raw upload fields here.
decoded.push(InboundAttachment {
id: format!("webui-attachment-{index}"),
mime_type: mime,
filename: filename.map(str::to_string),
bytes,
});
}
Ok(decoded)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Browser body for WebUI cancel-run mutation.
Expand Down
Loading
Loading