You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/14 16:09:35 UTC
(maka) 03/20: feat(runtime-host): transfer WorkHub attachments atomically
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 0cb2be88e6b0e4f7157761bd63e4800c8a4ab3ff
Author: Wang <m4...@gmail.com>
AuthorDate: Mon Sep 14 17:18:50 2026 +0800
feat(runtime-host): transfer WorkHub attachments atomically
Generated-by: OpenAI Codex
---
crates/event-log/src/artifacts.rs | 2 +
crates/event-log/src/artifacts/copy.rs | 113 ++++++++++++
crates/event-log/src/workhub.rs | 33 +++-
crates/event-log/tests/workhub_attachments.rs | 199 +++++++++++++++++++++
.../src/execution/prepare/attachments.rs | 90 +---------
crates/runtime/src/artifact.rs | 80 +++++++++
crates/runtime/src/workhub.rs | 43 ++++-
docs/rust-runtime.md | 4 +-
docs/rust-runtime.zh-CN.md | 2 +-
tests/fixtures/client-artifact-upload.mjs | 39 ++++
tests/fixtures/client-attachment-workflow.mjs | 20 +--
tests/fixtures/client-workhub-answer.mjs | 33 ++--
tests/fixtures/client-workhub-delegation.mjs | 54 ++++--
13 files changed, 550 insertions(+), 162 deletions(-)
diff --git a/crates/event-log/src/artifacts.rs b/crates/event-log/src/artifacts.rs
index 2e84bee4c0..04c0af25d7 100644
--- a/crates/event-log/src/artifacts.rs
+++ b/crates/event-log/src/artifacts.rs
@@ -17,7 +17,9 @@
* under the License.
*/
+mod copy;
mod read;
+pub(crate) use copy::copy_in_transaction;
mod records;
use crate::{EventLog, StoreError};
use maka_runtime::{
diff --git a/crates/event-log/src/artifacts/copy.rs b/crates/event-log/src/artifacts/copy.rs
new file mode 100644
index 0000000000..583dea342a
--- /dev/null
+++ b/crates/event-log/src/artifacts/copy.rs
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::records::{advance, invalid, read_record, require_session};
+use crate::StoreError;
+use maka_runtime::{
+ artifact::ArtifactSource,
+ attachment::{AttachmentRef, MAX_ATTACHMENT_BYTES, StorageRef},
+};
+use sqlx::SqliteConnection;
+
+/// Copy an explicitly authorized attachment without materializing its BLOB in the Host.
+/// The caller commits this together with the message that owns the destination.
+pub(crate) async fn copy_in_transaction(
+ tx: &mut SqliteConnection,
+ source: &AttachmentRef,
+ destination: &AttachmentRef,
+ turn: &str,
+ created_at: u64,
+) -> Result<(), StoreError> {
+ let StorageRef::SessionFile {
+ session_id: source_session,
+ relative_path: source_id,
+ } = &source.storage_ref
+ else {
+ return Err(invalid("Attachment source is not a Session Artifact"));
+ };
+ let StorageRef::SessionFile {
+ session_id: target_session,
+ relative_path: target_id,
+ } = &destination.storage_ref
+ else {
+ return Err(invalid("Attachment destination is not a Session Artifact"));
+ };
+ let (mut artifact, digest) = read_record(tx, source_session, source_id)
+ .await?
+ .ok_or_else(|| invalid("Delegated attachment source no longer exists"))?;
+ artifact.validate_attachment(source).map_err(invalid)?;
+ if artifact.size_bytes > MAX_ATTACHMENT_BYTES
+ || artifact.source == ArtifactSource::UserUpload
+ && artifact.summary.as_ref() != Some(&digest)
+ {
+ return Err(StoreError::ArtifactConflict);
+ }
+ require_session(tx, target_session).await?;
+ artifact.session_id = target_session.clone();
+ artifact.id = target_id.clone();
+ artifact.turn_id = turn.into();
+ artifact.created_at = created_at;
+ artifact.validate().map_err(invalid)?;
+ artifact.validate_attachment(destination).map_err(invalid)?;
+ let encoded = serde_json::to_string(&artifact)?;
+ if encoded.len() > super::MAX_RECORD_BYTES {
+ return Err(invalid("Copied attachment metadata exceeds limit"));
+ }
+ if let Some((existing, target_digest)) = read_record(tx, target_session, target_id).await? {
+ if existing != artifact || target_digest != digest {
+ return Err(StoreError::ArtifactConflict);
+ }
+ let same: bool = sqlx::query_scalar(
+ "SELECT EXISTS(SELECT 1 FROM artifacts s JOIN artifacts t
+ ON t.session_id = ?3 AND t.id = ?4 WHERE s.session_id = ?1 AND s.id = ?2
+ AND length(s.payload) = ?5 AND s.payload = t.payload)",
+ )
+ .bind(source_session)
+ .bind(source_id)
+ .bind(target_session)
+ .bind(target_id)
+ .bind(artifact.size_bytes as i64)
+ .fetch_one(tx)
+ .await?;
+ return if same {
+ Ok(())
+ } else {
+ Err(StoreError::ArtifactConflict)
+ };
+ }
+ let inserted = sqlx::query(
+ "INSERT INTO artifacts (session_id, id, created_at, record_json, content_sha256, payload)
+ SELECT ?3, ?4, ?5, ?6, content_sha256, payload FROM artifacts
+ WHERE session_id = ?1 AND id = ?2 AND length(payload) = ?7",
+ )
+ .bind(source_session)
+ .bind(source_id)
+ .bind(target_session)
+ .bind(target_id)
+ .bind(created_at as i64)
+ .bind(encoded)
+ .bind(artifact.size_bytes as i64)
+ .execute(&mut *tx)
+ .await?
+ .rows_affected();
+ if inserted != 1 {
+ return Err(StoreError::ArtifactConflict);
+ }
+ advance(tx, target_session).await
+}
diff --git a/crates/event-log/src/workhub.rs b/crates/event-log/src/workhub.rs
index 20b91ee02b..553dfdbd8b 100644
--- a/crates/event-log/src/workhub.rs
+++ b/crates/event-log/src/workhub.rs
@@ -138,18 +138,35 @@ pub(crate) async fn apply(
if crate::shell_runs::unsettled(tx, &delegation.target.session_id).await? {
return Err(StoreError::SessionBusy);
}
+ let admitted_at = event
+ .recorded_at
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_err(|_| invalid("invalid WorkHub admission time"))?
+ .as_millis()
+ .try_into()
+ .map_err(|_| invalid("invalid WorkHub admission time"))?;
+ let message = delegation.message(&content).map_err(invalid)?;
+ for (source, destination) in content
+ .attachments
+ .iter()
+ .flatten()
+ .zip(message.message.content.attachments.iter().flatten())
+ {
+ crate::artifacts::copy_in_transaction(
+ tx,
+ source,
+ destination,
+ &delegation.target.turn_id,
+ admitted_at,
+ )
+ .await?;
+ }
let admission = PendingMessageAdmission {
invocation: delegation.target.clone(),
steering_invocation: None,
- source: delegation.message(&content).map_err(invalid)?,
+ source: message,
required_tools: Default::default(),
- admitted_at: event
- .recorded_at
- .duration_since(std::time::UNIX_EPOCH)
- .map_err(|_| invalid("invalid WorkHub admission time"))?
- .as_millis()
- .try_into()
- .map_err(|_| invalid("invalid WorkHub admission time"))?,
+ admitted_at,
};
crate::message_admissions::insert::insert(
tx,
diff --git a/crates/event-log/tests/workhub_attachments.rs b/crates/event-log/tests/workhub_attachments.rs
new file mode 100644
index 0000000000..49fbb28db2
--- /dev/null
+++ b/crates/event-log/tests/workhub_attachments.rs
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use maka_event_log::EventLog;
+use maka_runtime::{
+ artifact::{Artifact, ArtifactKind, ArtifactSource, content_digest},
+ attachment::{AttachmentKind, AttachmentRef, StorageRef},
+ event::{EventWrite, Fact, Invocation, InvocationInput, RuntimeEvent},
+ input::MessageInput,
+ workhub::{COORDINATION_SESSION_ID, Delegation},
+};
+use serde_json::json;
+
+fn upload(id: &str) -> Artifact {
+ Artifact {
+ id: id.into(),
+ session_id: COORDINATION_SESSION_ID.into(),
+ turn_id: "upload".into(),
+ created_at: 1,
+ name: format!("{id}.txt"),
+ kind: ArtifactKind::File,
+ size_bytes: 65_536,
+ mime_type: Some("text/plain".into()),
+ source: ArtifactSource::UserUpload,
+ summary: Some(content_digest(&vec![b'x'; 65_536])),
+ }
+}
+fn attachment(artifact: &Artifact) -> AttachmentRef {
+ AttachmentRef {
+ kind: AttachmentKind::Other,
+ name: artifact.name.clone(),
+ mime_type: artifact.mime_type.clone().unwrap(),
+ bytes: artifact.size_bytes,
+ storage_ref: StorageRef::SessionFile {
+ session_id: artifact.session_id.clone(),
+ relative_path: artifact.id.clone(),
+ },
+ }
+}
+
+#[tokio::test]
+async fn delegation_copies_attachments_atomically_and_replays_without_the_deleted_source() {
+ let temp = tempfile::tempdir().unwrap();
+ let path = temp.path().join("events.sqlite");
+ let log = EventLog::open(&path).await.unwrap();
+ for session in [COORDINATION_SESSION_ID, "target"] {
+ log.create_session(session, "create", &json!({}), 1)
+ .await
+ .unwrap();
+ }
+ let first = upload("first");
+ let second = upload("second");
+ log.commit_artifact(first.clone(), vec![b'x'; 65_536])
+ .await
+ .unwrap();
+ log.commit_artifact(second.clone(), vec![b'x'; 65_536])
+ .await
+ .unwrap();
+ let coordinator = Invocation {
+ session_id: COORDINATION_SESSION_ID.into(),
+ turn_id: "source-turn".into(),
+ run_id: "source-run".into(),
+ invocation_id: "source-invocation".into(),
+ };
+ let content = MessageInput {
+ attachments: Some(vec![attachment(&first), attachment(&second)]),
+ .."Use both attachments".into()
+ };
+ let opening = EventWrite::plain(RuntimeEvent::new(
+ coordinator.clone(),
+ Fact::InvocationOpened {
+ configuration: None,
+ input: InvocationInput::Message {
+ content: content.clone(),
+ request_fingerprint: None,
+ source_messages: Vec::new(),
+ skill_invocation: None,
+ },
+ },
+ ))
+ .unwrap();
+ log.append(&opening).await.unwrap();
+ log.delete_user_artifact(COORDINATION_SESSION_ID, &second.id)
+ .await
+ .unwrap();
+ let delegation = Delegation {
+ action_id: "copy-action".into(),
+ request_fingerprint: content_digest(b"copy request"),
+ source_message_event_id: opening.event().id.clone(),
+ target_revision: 1,
+ target: Invocation {
+ session_id: "target".into(),
+ turn_id: "target-turn".into(),
+ run_id: "target-run".into(),
+ invocation_id: "target-invocation".into(),
+ },
+ delegation_text: "Read the evidence".into(),
+ };
+ let target_message = delegation.message(&content).unwrap();
+ let action = EventWrite::plain(RuntimeEvent::new(
+ coordinator,
+ Fact::WorkhubDelegated {
+ delegation: Box::new(delegation),
+ },
+ ))
+ .unwrap();
+ let initial = log.list_artifacts("target", 0, 128).await.unwrap().revision;
+ assert!(
+ log.append(&action).await.is_err(),
+ "the second source is missing"
+ );
+ assert_eq!(
+ log.list_artifacts("target", 0, 128).await.unwrap().revision,
+ initial
+ );
+ assert!(
+ log.list_artifacts("target", 0, 128)
+ .await
+ .unwrap()
+ .records
+ .is_empty()
+ );
+ assert!(log.pending_messages("target").await.unwrap().is_empty());
+ log.commit_artifact(second.clone(), vec![b'x'; 65_536])
+ .await
+ .unwrap();
+ let db = rusqlite::Connection::open(&path).unwrap();
+ db.execute_batch("CREATE TRIGGER fail_copy_action BEFORE INSERT ON runtime_events
+ WHEN NEW.kind = 'workhub_delegated' BEGIN SELECT RAISE(ABORT, 'injected action failure'); END;").unwrap();
+ assert!(log.append(&action).await.is_err());
+ assert_eq!(
+ log.list_artifacts("target", 0, 128).await.unwrap().revision,
+ initial
+ );
+ assert!(
+ log.list_artifacts("target", 0, 128)
+ .await
+ .unwrap()
+ .records
+ .is_empty()
+ );
+ assert!(log.pending_messages("target").await.unwrap().is_empty());
+ assert!(log.workhub_action("copy-action").await.unwrap().is_none());
+ db.execute_batch("DROP TRIGGER fail_copy_action;").unwrap();
+ drop(db);
+ let sequence = log.append(&action).await.unwrap();
+ assert_eq!(
+ log.pending_messages("target").await.unwrap()[0].source,
+ target_message
+ );
+ let records = log.list_artifacts("target", 0, 128).await.unwrap().records;
+ assert_eq!(records.len(), 2);
+ for copied in &records {
+ assert_eq!(copied.session_id, "target");
+ assert_eq!(copied.turn_id, "target-turn");
+ assert!(copied.id != first.id && copied.id != second.id);
+ assert_eq!(
+ log.read_artifact_chunk("target", &copied.id, 0, 65_536)
+ .await
+ .unwrap()
+ .unwrap()
+ .bytes,
+ vec![b'x'; 65_536]
+ );
+ }
+ for source in [&first, &second] {
+ log.delete_user_artifact(COORDINATION_SESSION_ID, &source.id)
+ .await
+ .unwrap();
+ }
+ log.close().await.unwrap();
+ let log = EventLog::open(&path).await.unwrap();
+ assert_eq!(log.append(&action).await.unwrap(), sequence);
+ assert_eq!(
+ log.list_artifacts("target", 0, 128).await.unwrap().records,
+ records
+ );
+ assert_eq!(
+ log.pending_messages("target").await.unwrap()[0].source,
+ target_message
+ );
+ log.close().await.unwrap();
+}
diff --git a/crates/runtime-host/src/execution/prepare/attachments.rs b/crates/runtime-host/src/execution/prepare/attachments.rs
index aaa56b8c77..4e015c5b6f 100644
--- a/crates/runtime-host/src/execution/prepare/attachments.rs
+++ b/crates/runtime-host/src/execution/prepare/attachments.rs
@@ -20,8 +20,7 @@
use super::{Code, Result, failure, internal};
use maka_event_log::EventLog;
use maka_runtime::{
- artifact::{Artifact, ArtifactKind},
- attachment::{AttachmentKind, AttachmentRef, StorageRef},
+ attachment::{AttachmentRef, StorageRef},
interaction::entity_id,
};
@@ -61,92 +60,9 @@ pub(super) async fn validate(
.map_err(internal)?
.record
.ok_or_else(|| failure(Code::OperationConflict, "Attachment Artifact was not found"))?;
- validate_descriptor(attachment, &record)
+ record
+ .validate_attachment(attachment)
.map_err(|message| failure(Code::OperationConflict, message))?;
}
Ok(())
}
-
-fn validate_descriptor(
- attachment: &AttachmentRef,
- record: &Artifact,
-) -> std::result::Result<(), &'static str> {
- if record.name != attachment.name
- || record.mime_type.as_deref() != Some(attachment.mime_type.as_str())
- || record.size_bytes != attachment.bytes
- {
- return Err("Attachment metadata does not match its canonical Artifact");
- }
- let kind = AttachmentKind::from_metadata(&attachment.mime_type, &record.name);
- if attachment.kind != kind
- || (kind == AttachmentKind::Image) != (record.kind == ArtifactKind::Image)
- || (kind == AttachmentKind::Pdf) != (record.kind == ArtifactKind::Pdf)
- {
- return Err("Attachment kind does not match its canonical Artifact");
- }
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use maka_runtime::artifact::ArtifactSource;
-
- #[test]
- fn canonical_metadata_and_bidirectional_modality_are_required_without_source_restriction() {
- let attachment = AttachmentRef {
- kind: AttachmentKind::Image,
- name: "picture.png".into(),
- mime_type: "image/png".into(),
- bytes: 4,
- storage_ref: StorageRef::SessionFile {
- session_id: "session".into(),
- relative_path: "artifact".into(),
- },
- };
- let mut record = Artifact {
- id: "artifact".into(),
- session_id: "session".into(),
- turn_id: "turn".into(),
- created_at: 1,
- name: attachment.name.clone(),
- kind: ArtifactKind::Image,
- size_bytes: attachment.bytes,
- mime_type: Some(attachment.mime_type.clone()),
- source: ArtifactSource::ToolResultProjection,
- summary: None,
- };
- assert!(validate_descriptor(&attachment, &record).is_ok());
- for changed in [
- AttachmentRef {
- name: "other.png".into(),
- ..attachment.clone()
- },
- AttachmentRef {
- mime_type: "image/jpeg".into(),
- ..attachment.clone()
- },
- AttachmentRef {
- bytes: 5,
- ..attachment.clone()
- },
- AttachmentRef {
- kind: AttachmentKind::Other,
- ..attachment.clone()
- },
- ] {
- assert!(validate_descriptor(&changed, &record).is_err());
- }
- record.kind = ArtifactKind::File;
- assert!(validate_descriptor(&attachment, &record).is_err());
- let mut text = attachment;
- text.mime_type = "text/plain".into();
- text.kind = AttachmentKind::Other;
- record.mime_type = Some(text.mime_type.clone());
- assert!(validate_descriptor(&text, &record).is_ok());
- for kind in [ArtifactKind::Image, ArtifactKind::Pdf] {
- record.kind = kind;
- assert!(validate_descriptor(&text, &record).is_err());
- }
- }
-}
diff --git a/crates/runtime/src/artifact.rs b/crates/runtime/src/artifact.rs
index 8df5e0a68d..ac192e2330 100644
--- a/crates/runtime/src/artifact.rs
+++ b/crates/runtime/src/artifact.rs
@@ -75,6 +75,27 @@ pub struct Artifact {
}
impl Artifact {
+ pub fn validate_attachment(
+ &self,
+ attachment: &crate::attachment::AttachmentRef,
+ ) -> Result<(), &'static str> {
+ use crate::attachment::AttachmentKind;
+ if self.name != attachment.name
+ || self.mime_type.as_deref() != Some(attachment.mime_type.as_str())
+ || self.size_bytes != attachment.bytes
+ {
+ return Err("Attachment metadata does not match its canonical Artifact");
+ }
+ let kind = AttachmentKind::from_metadata(&attachment.mime_type, &self.name);
+ if attachment.kind != kind
+ || (kind == AttachmentKind::Image) != (self.kind == ArtifactKind::Image)
+ || (kind == AttachmentKind::Pdf) != (self.kind == ArtifactKind::Pdf)
+ {
+ return Err("Attachment kind does not match its canonical Artifact");
+ }
+ Ok(())
+ }
+
pub fn validate(&self) -> Result<(), &'static str> {
crate::interaction::entity_id(&self.id)?;
crate::interaction::entity_id(&self.session_id)?;
@@ -157,6 +178,65 @@ fn js_whitespace(character: char) -> bool {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::attachment::{AttachmentKind, AttachmentRef, StorageRef};
+
+ #[test]
+ fn canonical_metadata_and_bidirectional_modality_are_required_without_source_restriction() {
+ let attachment = AttachmentRef {
+ kind: AttachmentKind::Image,
+ name: "picture.png".into(),
+ mime_type: "image/png".into(),
+ bytes: 4,
+ storage_ref: StorageRef::SessionFile {
+ session_id: "session".into(),
+ relative_path: "artifact".into(),
+ },
+ };
+ let mut record = Artifact {
+ id: "artifact".into(),
+ session_id: "session".into(),
+ turn_id: "turn".into(),
+ created_at: 1,
+ name: attachment.name.clone(),
+ kind: ArtifactKind::Image,
+ size_bytes: attachment.bytes,
+ mime_type: Some(attachment.mime_type.clone()),
+ source: ArtifactSource::ToolResultProjection,
+ summary: None,
+ };
+ assert!(record.validate_attachment(&attachment).is_ok());
+ for changed in [
+ AttachmentRef {
+ name: "other.png".into(),
+ ..attachment.clone()
+ },
+ AttachmentRef {
+ mime_type: "image/jpeg".into(),
+ ..attachment.clone()
+ },
+ AttachmentRef {
+ bytes: 5,
+ ..attachment.clone()
+ },
+ AttachmentRef {
+ kind: AttachmentKind::Other,
+ ..attachment.clone()
+ },
+ ] {
+ assert!(record.validate_attachment(&changed).is_err());
+ }
+ record.kind = ArtifactKind::File;
+ assert!(record.validate_attachment(&attachment).is_err());
+ let mut text = attachment;
+ text.mime_type = "text/plain".into();
+ text.kind = AttachmentKind::Other;
+ record.mime_type = Some(text.mime_type.clone());
+ assert!(record.validate_attachment(&text).is_ok());
+ for kind in [ArtifactKind::Image, ArtifactKind::Pdf] {
+ record.kind = kind;
+ assert!(record.validate_attachment(&text).is_err());
+ }
+ }
#[test]
fn names_are_idempotent_with_javascript_whitespace_and_utf16_truncation() {
diff --git a/crates/runtime/src/workhub.rs b/crates/runtime/src/workhub.rs
index f14788fcb6..1f533b1767 100644
--- a/crates/runtime/src/workhub.rs
+++ b/crates/runtime/src/workhub.rs
@@ -19,6 +19,7 @@
//! Durable WorkHub delegation identities; execution remains ordinary queued work.
use crate::{
+ attachment::{AttachmentRef, StorageRef},
event::Invocation,
input::{DeliveredMessage, MessageInput},
message::{MessageDisposition, Placement, RootSourceMessage},
@@ -70,19 +71,22 @@ impl Delegation {
/// Derive the sole target message from its canonical source and recorded task.
pub fn message(&self, user: &MessageInput) -> Result<RootSourceMessage, &'static str> {
- if user
- .attachments
- .as_ref()
- .is_some_and(|items| !items.is_empty())
- {
- return Err("WorkHub attachment transfer is not installed");
- }
let content = MessageInput {
text: format!(
"User request:\n{}\n\nDelegated task:\n{}",
user.text, self.delegation_text
),
display_text: None,
+ attachments: user
+ .attachments
+ .as_ref()
+ .map(|items| {
+ items
+ .iter()
+ .map(|attachment| self.attachment(attachment))
+ .collect()
+ })
+ .transpose()?,
..user.clone()
};
if content.text_bytes() > 64 * 1024 {
@@ -107,4 +111,29 @@ impl Delegation {
message.validate()?;
Ok(message)
}
+
+ /// Stable destination; only the canonical coordination message supplies sources.
+ pub fn attachment(&self, source: &AttachmentRef) -> Result<AttachmentRef, &'static str> {
+ let StorageRef::SessionFile {
+ session_id,
+ relative_path,
+ } = &source.storage_ref
+ else {
+ return Err("WorkHub attachments require Session Artifact references");
+ };
+ if session_id != COORDINATION_SESSION_ID {
+ return Err("WorkHub attachment belongs to another Session");
+ }
+ crate::interaction::entity_id(relative_path)?;
+ Ok(AttachmentRef {
+ storage_ref: StorageRef::SessionFile {
+ session_id: self.target.session_id.clone(),
+ relative_path: crate::artifact::upload_artifact_id(
+ &self.target.session_id,
+ &format!("workhub:{}:{relative_path}", self.action_id),
+ ),
+ },
+ ..source.clone()
+ })
+ }
}
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index fba9c3639a..dae3b6d42a 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -159,8 +159,8 @@ live verification are deferred.
WorkHub supports session resolution, queries, model configuration and conversation
with scoped Desktop tools and attachment reads, plus candidate discovery and delegation
-to existing idle sessions. Task creation, linked operations, multi-selection and attachment
-transfer remain incomplete.
+to existing idle sessions with atomic attachment transfer. Task creation, linked operations
+and multi-selection remain incomplete.
Skill mutations and update previews,
advanced recovery/reconciliation, WorkHub coordination and orchestration,
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 77b171d525..eee5920cef 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -132,7 +132,7 @@ Grep 差分测试需要 PATH 中有 `rg`;runtime 本身不依赖该可执行
模型流式交互与上下文压缩。Codex 订阅已接入执行;Copilot/xAI 推理适配和实测后置。
WorkHub 已支持专属会话解析、查询、模型配置、受限 Desktop 工具与附件读取,以及候选发现和向已有空闲会话委派任务。
-任务创建、关联操作、多选及附件转移仍未完成。
+委派时原子转移附件;任务创建、关联操作和多选仍未完成。
Skills 变更与更新预览、高级恢复与协调、WorkHub 协调、
编排、部分 capability 服务、受管升级及其它协议域
diff --git a/tests/fixtures/client-artifact-upload.mjs b/tests/fixtures/client-artifact-upload.mjs
new file mode 100644
index 0000000000..34030819f6
--- /dev/null
+++ b/tests/fixtures/client-artifact-upload.mjs
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createHash } from 'node:crypto';
+
+export async function upload(request, sessionId, uploadId, bytes, name, mimeType) {
+ const command = { sessionId, uploadId };
+ await request('artifact.ingest', {
+ ...command,
+ kind: 'begin',
+ name,
+ mimeType,
+ totalBytes: bytes.length,
+ contentSha256: 'sha256:' + createHash('sha256').update(bytes).digest('hex'),
+ });
+ await request('artifact.ingest', {
+ ...command,
+ kind: 'chunk',
+ offset: 0,
+ chunkBase64: bytes.toString('base64'),
+ });
+ return (await request('artifact.ingest', { ...command, kind: 'commit' })).attachment;
+}
diff --git a/tests/fixtures/client-attachment-workflow.mjs b/tests/fixtures/client-attachment-workflow.mjs
index 5fe4b5c412..980c7f8d44 100644
--- a/tests/fixtures/client-attachment-workflow.mjs
+++ b/tests/fixtures/client-attachment-workflow.mjs
@@ -18,7 +18,7 @@
*/
import assert from 'node:assert/strict';
-import { createHash } from 'node:crypto';
+import { upload } from './client-artifact-upload.mjs';
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
@@ -35,24 +35,6 @@ async function rows(connection, sessionId) {
await observer.close();
}
}
-async function upload(request, sessionId, uploadId, bytes, name, mimeType) {
- const command = { sessionId, uploadId };
- await request('artifact.ingest', {
- ...command,
- kind: 'begin',
- name,
- mimeType,
- totalBytes: bytes.length,
- contentSha256: 'sha256:' + createHash('sha256').update(bytes).digest('hex'),
- });
- await request('artifact.ingest', {
- ...command,
- kind: 'chunk',
- offset: 0,
- chunkBase64: bytes.toString('base64'),
- });
- return (await request('artifact.ingest', { ...command, kind: 'commit' })).attachment;
-}
export async function verifyConsumption(connection, workspace, reopened) {
const request = (operation, input) => connection.request(operation, input, 3000);
const snapshot = join(workspace, 'attachments.json');
diff --git a/tests/fixtures/client-workhub-answer.mjs b/tests/fixtures/client-workhub-answer.mjs
index 8fc2c719bf..41a7a5ad6b 100644
--- a/tests/fixtures/client-workhub-answer.mjs
+++ b/tests/fixtures/client-workhub-answer.mjs
@@ -18,7 +18,7 @@
*/
import assert from 'node:assert/strict';
-import { createHash } from 'node:crypto';
+import { upload } from './client-artifact-upload.mjs';
import { once } from 'node:events';
import { createServer } from 'node:http';
import { readFile, writeFile } from 'node:fs/promises';
@@ -155,29 +155,16 @@ export async function verifyWorkhubAnswer(connection, workspace, reopened) {
const session = await request('workhub.coordination.query', {});
await writeFile(join(session.workspace.hostCwd, 'AGENTS.md'), forbidden);
const bytes = Buffer.from('ATTACHMENT_EVIDENCE\nsecond line\n');
- await request('artifact.ingest', {
- kind: 'begin',
+ const attachment = await upload(
+ request,
sessionId,
- uploadId: 'workhub-upload',
- name: 'evidence.txt',
- mimeType: 'text/plain',
- totalBytes: bytes.length,
- contentSha256: 'sha256:' + createHash('sha256').update(bytes).digest('hex'),
- });
- await request('artifact.ingest', {
- kind: 'chunk',
- sessionId,
- uploadId: 'workhub-upload',
- offset: 0,
- chunkBase64: bytes.toString('base64'),
- });
- const uploaded = await request('artifact.ingest', {
- kind: 'commit',
- sessionId,
- uploadId: 'workhub-upload',
- });
- attachmentPath = 'maka://runtime/attachments/' + uploaded.attachment.ref.relativePath;
- const input = { turnId, text: '请检查附件并确认 WorkHub', attachments: [uploaded.attachment] };
+ 'workhub-upload',
+ bytes,
+ 'evidence.txt',
+ 'text/plain',
+ );
+ attachmentPath = 'maka://runtime/attachments/' + attachment.ref.relativePath;
+ const input = { turnId, text: '请检查附件并确认 WorkHub', attachments: [attachment] };
await assert.rejects(
request('workhub.coordination.answer', input),
(e) => e.code === 'operation_unavailable',
diff --git a/tests/fixtures/client-workhub-delegation.mjs b/tests/fixtures/client-workhub-delegation.mjs
index 9285e64d84..d469bf22f2 100644
--- a/tests/fixtures/client-workhub-delegation.mjs
+++ b/tests/fixtures/client-workhub-delegation.mjs
@@ -25,6 +25,7 @@ import { join } from 'node:path';
import { decodeStoredMessage } from '../../packages/core/src/session.ts';
import { watchSession } from './client-subscription.mjs';
import { createInput, querySession } from './client-runtime-policy-fixture.mjs';
+import { upload } from './client-artifact-upload.mjs';
const sessionId = 'maka_workhub_coordination';
const turnId = 'delegate-request';
@@ -57,7 +58,8 @@ export async function verifyWorkhubDelegation(connection, workspace, reopened) {
let failure,
calls = 0,
input,
- receipt;
+ receipt,
+ attachment;
const server = createServer(async (req, response) => {
try {
let body = '';
@@ -69,20 +71,29 @@ export async function verifyWorkhubDelegation(connection, workspace, reopened) {
message.content.includes('Delegated task:\nImplement the requested task'),
);
const finished = data.messages.some((message) => message.role === 'tool');
- const delta = target
- ? { content: 'target completed' }
- : finished
- ? { content: 'delegation completed' }
- : {
- tool_calls: [
- {
- index: 0,
- id: 'delegate-call',
- type: 'function',
- function: { name: 'mcp__desktop_workhub__tasks', arguments: '{}' },
+ const path = target
+ ? body.match(/maka:\/\/runtime\/attachments\/[A-Za-z0-9_-]+/u)?.[0]
+ : undefined;
+ if (target) {
+ assert(path);
+ assert(!path.endsWith('/' + attachment.ref.relativePath));
+ if (finished) assert(body.includes('TRANSFER_EVIDENCE'));
+ }
+ const delta = finished
+ ? { content: target ? 'target completed' : 'delegation completed' }
+ : {
+ tool_calls: [
+ {
+ index: 0,
+ id: 'delegate-call',
+ type: 'function',
+ function: {
+ name: target ? 'Read' : 'mcp__desktop_workhub__tasks',
+ arguments: JSON.stringify(target ? { path } : {}),
},
- ],
- };
+ },
+ ],
+ };
if (target) {
assert(
data.messages.some(
@@ -102,7 +113,7 @@ export async function verifyWorkhubDelegation(connection, workspace, reopened) {
});
response.writeHead(200, { 'Content-Type': 'text/event-stream', Connection: 'close' });
response.end(
- [chunk(delta, null), chunk({}, target || finished ? 'stop' : 'tool_calls')]
+ [chunk(delta, null), chunk({}, finished ? 'stop' : 'tool_calls')]
.map((event) => 'data: ' + JSON.stringify(event) + '\n\n')
.join('') + 'data: [DONE]\n\n',
);
@@ -152,6 +163,14 @@ export async function verifyWorkhubDelegation(connection, workspace, reopened) {
await request('session.create', { ...createInput(workspace, id, 'bypass'), ...extra });
await request('session.lifecycle.set', { sessionId: 'archived', state: 'archived' });
await request('workhub.coordination.resolve', {});
+ attachment = await upload(
+ request,
+ sessionId,
+ 'transfer',
+ Buffer.from('TRANSFER_EVIDENCE'),
+ 'evidence.txt',
+ 'text/plain',
+ );
const initial = await request('workhub.coordination.candidates', {});
assert.deepEqual(
initial.candidates.map((candidate) => candidate.sessionId),
@@ -210,6 +229,10 @@ export async function verifyWorkhubDelegation(connection, workspace, reopened) {
receipt = await act(input);
assert.equal(receipt.disposition, 'delegate_existing');
assert.equal(receipt.targetSessionId, 'target');
+ await request('artifact.delete', {
+ sessionId,
+ artifactId: attachment.ref.relativePath,
+ });
assert.deepEqual(await act(input), receipt);
await assert.rejects(
act({ ...input, delegationText: 'changed request' }),
@@ -230,6 +253,7 @@ export async function verifyWorkhubDelegation(connection, workspace, reopened) {
await request('workhub.coordination.answer', {
turnId,
text: 'Please implement the requested task',
+ attachments: [attachment],
});
await sourceObserver.waitFor(
(frame) =>
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org