Separate Console image bodies from session snapshots
This commit is contained in:
@@ -1037,10 +1037,11 @@ pub enum SessionContentPart {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SessionToolAttachment {
|
pub struct SessionToolAttachment {
|
||||||
|
/// Stable, bounded identity derived from the owning Session entry, attachment
|
||||||
|
/// position, and immutable image bytes. It is a locator, not a bearer secret.
|
||||||
|
pub attachment_id: String,
|
||||||
pub media_type: String,
|
pub media_type: String,
|
||||||
/// Base64-encoded durable attachment body. Public snapshots preserve the
|
pub byte_len: u64,
|
||||||
/// committed multimodal value instead of replacing it with placeholder text.
|
|
||||||
pub data_base64: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1085,6 +1085,8 @@ pub const RUNTIME_ROUTE_WORKER_PROTOCOL_WS: &str = "/v1/workers/{worker_id}/prot
|
|||||||
pub const RUNTIME_ROUTE_WORKER_ATTACHMENTS: &str = "/v1/workers/{worker_id}/attachments";
|
pub const RUNTIME_ROUTE_WORKER_ATTACHMENTS: &str = "/v1/workers/{worker_id}/attachments";
|
||||||
pub const RUNTIME_ROUTE_WORKER_ATTACHMENT: &str =
|
pub const RUNTIME_ROUTE_WORKER_ATTACHMENT: &str =
|
||||||
"/v1/workers/{worker_id}/attachments/{artifact_id}";
|
"/v1/workers/{worker_id}/attachments/{artifact_id}";
|
||||||
|
pub const RUNTIME_ROUTE_WORKER_SESSION_ATTACHMENT: &str =
|
||||||
|
"/v1/workers/{worker_id}/sessions/{session_id}/attachments/{attachment_id}";
|
||||||
|
|
||||||
/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather
|
/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather
|
||||||
/// than letting them be mistaken for management-contract omissions.
|
/// than letting them be mistaken for management-contract omissions.
|
||||||
@@ -1189,6 +1191,11 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
|
|||||||
path: RUNTIME_ROUTE_WORKER_ATTACHMENT,
|
path: RUNTIME_ROUTE_WORKER_ATTACHMENT,
|
||||||
reason: "Binary attachment transport",
|
reason: "Binary attachment transport",
|
||||||
},
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "GET",
|
||||||
|
path: RUNTIME_ROUTE_WORKER_SESSION_ATTACHMENT,
|
||||||
|
reason: "Session image attachment transport",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1478,7 +1485,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remaining_route_inventory_does_not_overlap_contract() {
|
fn remaining_route_inventory_does_not_overlap_contract() {
|
||||||
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 20);
|
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 21);
|
||||||
for remaining in REMAINING_RUNTIME_ROUTES {
|
for remaining in REMAINING_RUNTIME_ROUTES {
|
||||||
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
|
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
|
||||||
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
|
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
|
||||||
|
|||||||
@@ -110,9 +110,11 @@ pub use public_snapshot::{
|
|||||||
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
||||||
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
||||||
DEFAULT_RETAINED_HISTORY_PAGE_TURNS, DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
DEFAULT_RETAINED_HISTORY_PAGE_TURNS, DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||||
MAX_RETAINED_HISTORY_PAGE_TURNS, RetainedHistoryReadError, RetainedHistoryReadLimits,
|
MAX_RETAINED_HISTORY_PAGE_TURNS, RetainedAttachmentReadError, RetainedHistoryReadError,
|
||||||
RetainedSessionIdentity, RetainedSessionSnapshot, RetainedSnapshotReadError,
|
RetainedHistoryReadLimits, RetainedSessionAttachment, RetainedSessionIdentity,
|
||||||
project_session_snapshot, read_retained_session_history_page, read_retained_session_snapshot,
|
RetainedSessionSnapshot, RetainedSnapshotReadError, project_session_snapshot,
|
||||||
|
read_retained_session_attachment, read_retained_session_history_page,
|
||||||
|
read_retained_session_snapshot, session_tool_attachment_id,
|
||||||
};
|
};
|
||||||
pub use segment::{
|
pub use segment::{
|
||||||
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
use base64::{
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
Engine as _,
|
|
||||||
engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD},
|
|
||||||
};
|
|
||||||
use protocol::{
|
use protocol::{
|
||||||
Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||||
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
||||||
@@ -54,6 +51,32 @@ pub enum RetainedSnapshotReadError {
|
|||||||
SnapshotTooLarge,
|
SnapshotTooLarge,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RetainedSessionAttachment {
|
||||||
|
pub media_type: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum RetainedAttachmentReadError {
|
||||||
|
#[error("retained worker state is unavailable")]
|
||||||
|
RetentionMissing,
|
||||||
|
#[error("retained worker state has no active session pointer")]
|
||||||
|
ActivePointerMissing,
|
||||||
|
#[error("retained session identity does not match")]
|
||||||
|
SessionMismatch,
|
||||||
|
#[error("session attachment was not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("retained session requires migration")]
|
||||||
|
MigrationRequired,
|
||||||
|
#[error("retained session log is corrupt")]
|
||||||
|
CorruptLog,
|
||||||
|
#[error("retained session storage is unavailable")]
|
||||||
|
StorageUnavailable,
|
||||||
|
#[error("retained session attachment exceeds a resource limit")]
|
||||||
|
ResourceLimit,
|
||||||
|
}
|
||||||
|
|
||||||
pub const DEFAULT_RETAINED_HISTORY_PAGE_TURNS: usize = 5;
|
pub const DEFAULT_RETAINED_HISTORY_PAGE_TURNS: usize = 5;
|
||||||
pub const MAX_RETAINED_HISTORY_PAGE_TURNS: usize = 5;
|
pub const MAX_RETAINED_HISTORY_PAGE_TURNS: usize = 5;
|
||||||
pub const DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES: u64 = 64 * 1024 * 1024;
|
pub const DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
@@ -175,7 +198,9 @@ pub fn read_retained_session_snapshot(
|
|||||||
.ok_or(RetainedSnapshotReadError::ActivePointerMissing)?;
|
.ok_or(RetainedSnapshotReadError::ActivePointerMissing)?;
|
||||||
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session"))
|
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session"))
|
||||||
.map_err(map_store_error)?;
|
.map_err(map_store_error)?;
|
||||||
if store.segment_log_len(segment_id).map_err(map_store_error)? > max_bytes {
|
if store.segment_log_len(segment_id).map_err(map_store_error)?
|
||||||
|
> DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES
|
||||||
|
{
|
||||||
return Err(RetainedSnapshotReadError::SnapshotTooLarge);
|
return Err(RetainedSnapshotReadError::SnapshotTooLarge);
|
||||||
}
|
}
|
||||||
let entries = store
|
let entries = store
|
||||||
@@ -199,6 +224,101 @@ pub fn read_retained_session_snapshot(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve one immutable image body from the active retained Session. The
|
||||||
|
/// caller supplies only public identities; storage paths never cross this API.
|
||||||
|
pub fn read_retained_session_attachment(
|
||||||
|
aggregate_root: &Path,
|
||||||
|
worker_name: &str,
|
||||||
|
expected_session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
max_scan_bytes: u64,
|
||||||
|
max_attachment_bytes: u64,
|
||||||
|
) -> Result<RetainedSessionAttachment, RetainedAttachmentReadError> {
|
||||||
|
let aggregate = WorkerAggregateStore::open_read_only(aggregate_root, worker_name)
|
||||||
|
.map_err(map_attachment_worker_store_error)?;
|
||||||
|
let metadata = aggregate
|
||||||
|
.read_read_only()
|
||||||
|
.map_err(map_attachment_worker_store_error)?
|
||||||
|
.ok_or(RetainedAttachmentReadError::RetentionMissing)?;
|
||||||
|
let active = metadata
|
||||||
|
.active
|
||||||
|
.ok_or(RetainedAttachmentReadError::ActivePointerMissing)?;
|
||||||
|
if active.session_id.to_string() != expected_session_id {
|
||||||
|
return Err(RetainedAttachmentReadError::SessionMismatch);
|
||||||
|
}
|
||||||
|
let segment_id = active
|
||||||
|
.segment_id
|
||||||
|
.ok_or(RetainedAttachmentReadError::ActivePointerMissing)?;
|
||||||
|
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session"))
|
||||||
|
.map_err(map_attachment_store_error)?;
|
||||||
|
if store
|
||||||
|
.segment_log_len(segment_id)
|
||||||
|
.map_err(map_attachment_store_error)?
|
||||||
|
> max_scan_bytes
|
||||||
|
{
|
||||||
|
return Err(RetainedAttachmentReadError::ResourceLimit);
|
||||||
|
}
|
||||||
|
let entries = store
|
||||||
|
.read_all_read_only(active.session_id, segment_id)
|
||||||
|
.map_err(map_attachment_store_error)?;
|
||||||
|
for record in &entries {
|
||||||
|
let mut found = None;
|
||||||
|
visit_record_history(record, &mut |entry| {
|
||||||
|
if found.is_none() {
|
||||||
|
found = attachment_from_entry(entry, attachment_id, max_attachment_bytes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(result) = found {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RetainedAttachmentReadError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_record_history(record: &LogEntry, visit: &mut impl FnMut(&LoggedHistoryEntry)) {
|
||||||
|
match record {
|
||||||
|
LogEntry::AnnotatedSegmentStart { history, .. }
|
||||||
|
| LogEntry::AnnotatedUserInput { history, .. } => {
|
||||||
|
for entry in history {
|
||||||
|
visit(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||||
|
| LogEntry::AnnotatedToolResult { entry, .. } => visit(entry),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_from_entry(
|
||||||
|
entry: &LoggedHistoryEntry,
|
||||||
|
requested_id: &str,
|
||||||
|
max_attachment_bytes: u64,
|
||||||
|
) -> Option<Result<RetainedSessionAttachment, RetainedAttachmentReadError>> {
|
||||||
|
let LoggedItem::ToolResult { attachments, .. } = &entry.item else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
for (attachment_index, attachment) in attachments.iter().enumerate() {
|
||||||
|
let crate::logged_item::LoggedAttachment::Image { mime_type, data } = attachment;
|
||||||
|
if session_tool_attachment_id(
|
||||||
|
&entry.metadata.entry_id.0,
|
||||||
|
attachment_index,
|
||||||
|
mime_type,
|
||||||
|
data,
|
||||||
|
) != requested_id
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if data.len() as u64 > max_attachment_bytes {
|
||||||
|
return Some(Err(RetainedAttachmentReadError::ResourceLimit));
|
||||||
|
}
|
||||||
|
return Some(Ok(RetainedSessionAttachment {
|
||||||
|
media_type: mime_type.clone(),
|
||||||
|
data: data.clone(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Read one bounded backward page from the retained active Segment's adopted
|
/// Read one bounded backward page from the retained active Segment's adopted
|
||||||
/// lineage without creating, migrating, restoring, or rewriting persisted state.
|
/// lineage without creating, migrating, restoring, or rewriting persisted state.
|
||||||
///
|
///
|
||||||
@@ -935,6 +1055,35 @@ fn map_history_store_error(error: StoreError) -> RetainedHistoryReadError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_attachment_worker_store_error(error: WorkerStoreError) -> RetainedAttachmentReadError {
|
||||||
|
match error {
|
||||||
|
WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
RetainedAttachmentReadError::RetentionMissing
|
||||||
|
}
|
||||||
|
WorkerStoreError::Serde(_) | WorkerStoreError::InvalidWorkerName(_) => {
|
||||||
|
RetainedAttachmentReadError::CorruptLog
|
||||||
|
}
|
||||||
|
WorkerStoreError::Io(_) => RetainedAttachmentReadError::StorageUnavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_attachment_store_error(error: StoreError) -> RetainedAttachmentReadError {
|
||||||
|
match error {
|
||||||
|
StoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
RetainedAttachmentReadError::RetentionMissing
|
||||||
|
}
|
||||||
|
StoreError::Corrupt { message, .. } if message.contains("requires migration") => {
|
||||||
|
RetainedAttachmentReadError::MigrationRequired
|
||||||
|
}
|
||||||
|
StoreError::ReadLimitExceeded => RetainedAttachmentReadError::ResourceLimit,
|
||||||
|
StoreError::Io(_) => RetainedAttachmentReadError::StorageUnavailable,
|
||||||
|
StoreError::Serde(_) | StoreError::Corrupt { .. } | StoreError::NotFound(_) => {
|
||||||
|
RetainedAttachmentReadError::CorruptLog
|
||||||
|
}
|
||||||
|
_ => RetainedAttachmentReadError::CorruptLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn map_worker_store_error(error: WorkerStoreError) -> RetainedSnapshotReadError {
|
fn map_worker_store_error(error: WorkerStoreError) -> RetainedSnapshotReadError {
|
||||||
match error {
|
match error {
|
||||||
WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
@@ -1028,7 +1177,7 @@ pub(crate) fn project_session_snapshot_for_segment(
|
|||||||
} => extend_history(&mut entries, history, Some(segments), *ts),
|
} => extend_history(&mut entries, history, Some(segments), *ts),
|
||||||
LogEntry::AnnotatedAssistantItem { ts, entry }
|
LogEntry::AnnotatedAssistantItem { ts, entry }
|
||||||
| LogEntry::AnnotatedToolResult { ts, entry } => {
|
| LogEntry::AnnotatedToolResult { ts, entry } => {
|
||||||
if let Some(data) = project_item(&entry.item) {
|
if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) {
|
||||||
entries.push(history_entry(entry, *ts, data));
|
entries.push(history_entry(entry, *ts, data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1188,7 +1337,7 @@ fn extend_history(
|
|||||||
segments: input_segments.cloned().unwrap_or_default(),
|
segments: input_segments.cloned().unwrap_or_default(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let Some(data) = project_item(&entry.item) else {
|
let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
data
|
data
|
||||||
@@ -1306,7 +1455,25 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
pub fn session_tool_attachment_id(
|
||||||
|
entry_id: &str,
|
||||||
|
attachment_index: usize,
|
||||||
|
media_type: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> String {
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(b"yoi-session-tool-attachment-v1\0");
|
||||||
|
hash.update((entry_id.len() as u64).to_be_bytes());
|
||||||
|
hash.update(entry_id.as_bytes());
|
||||||
|
hash.update((attachment_index as u64).to_be_bytes());
|
||||||
|
hash.update((media_type.len() as u64).to_be_bytes());
|
||||||
|
hash.update(media_type.as_bytes());
|
||||||
|
hash.update((data.len() as u64).to_be_bytes());
|
||||||
|
hash.update(data);
|
||||||
|
URL_SAFE_NO_PAD.encode(hash.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_item(entry_id: &str, item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
||||||
match item {
|
match item {
|
||||||
LoggedItem::Message { role, content } => {
|
LoggedItem::Message { role, content } => {
|
||||||
let role = match role {
|
let role = match role {
|
||||||
@@ -1355,11 +1522,18 @@ fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
|||||||
is_error: *is_error,
|
is_error: *is_error,
|
||||||
attachments: attachments
|
attachments: attachments
|
||||||
.iter()
|
.iter()
|
||||||
.map(|attachment| match attachment {
|
.enumerate()
|
||||||
|
.map(|(attachment_index, attachment)| match attachment {
|
||||||
crate::logged_item::LoggedAttachment::Image { mime_type, data } => {
|
crate::logged_item::LoggedAttachment::Image { mime_type, data } => {
|
||||||
SessionToolAttachment {
|
SessionToolAttachment {
|
||||||
|
attachment_id: session_tool_attachment_id(
|
||||||
|
entry_id,
|
||||||
|
attachment_index,
|
||||||
|
mime_type,
|
||||||
|
data,
|
||||||
|
),
|
||||||
media_type: mime_type.clone(),
|
media_type: mime_type.clone(),
|
||||||
data_base64: BASE64.encode(data),
|
byte_len: data.len() as u64,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -3307,6 +3481,120 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_legacy_inline_images_project_as_bounded_refs_and_fetch_from_retained_storage() {
|
||||||
|
let worker_name = "worker-images";
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let segment_id = crate::new_segment_id();
|
||||||
|
// Approximate the decoded byte sizes of the four W-296 screenshots.
|
||||||
|
let sizes = [3_475_194_usize, 3_459_051, 5_735_340, 5_703_924];
|
||||||
|
let attachments = sizes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(
|
||||||
|
|(index, size)| crate::logged_item::LoggedAttachment::Image {
|
||||||
|
mime_type: "image/png".into(),
|
||||||
|
data: vec![index as u8 + 1; *size],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
let tool_result = LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::ToolResult {
|
||||||
|
call_id: "call-images".into(),
|
||||||
|
summary: "four screenshots".into(),
|
||||||
|
content: None,
|
||||||
|
attachments,
|
||||||
|
disposition: agen::tool::ToolResultDisposition::Success,
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId("tool-images".into()),
|
||||||
|
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let live = project_current_session_snapshot(&[LogEntry::AnnotatedToolResult {
|
||||||
|
ts: 2,
|
||||||
|
entry: tool_result.clone(),
|
||||||
|
}]);
|
||||||
|
let log = vec![LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 1,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: RequestConfig::default(),
|
||||||
|
history: vec![tool_result],
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
}];
|
||||||
|
let (_root, aggregate_root) =
|
||||||
|
persist_history_fixture(worker_name, session_id, segment_id, vec![(segment_id, log)]);
|
||||||
|
|
||||||
|
let retained = read_retained_session_snapshot(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let SessionSnapshotEntryData::ToolResult { attachments, .. } =
|
||||||
|
&retained.snapshot.entries[0].data
|
||||||
|
else {
|
||||||
|
panic!("expected tool result");
|
||||||
|
};
|
||||||
|
assert_eq!(attachments.len(), 4);
|
||||||
|
let SessionSnapshotEntryData::ToolResult {
|
||||||
|
attachments: live_attachments,
|
||||||
|
..
|
||||||
|
} = &live.entries[0].data
|
||||||
|
else {
|
||||||
|
panic!("expected live tool result");
|
||||||
|
};
|
||||||
|
assert_eq!(live_attachments, attachments);
|
||||||
|
let snapshot_json = serde_json::to_vec(&retained.snapshot).unwrap();
|
||||||
|
assert!(snapshot_json.len() < 16 * 1024);
|
||||||
|
assert!(!String::from_utf8_lossy(&snapshot_json).contains("data_base64"));
|
||||||
|
|
||||||
|
for (index, attachment) in attachments.iter().enumerate() {
|
||||||
|
assert_eq!(attachment.byte_len, sizes[index] as u64);
|
||||||
|
assert_eq!(attachment.attachment_id.len(), 43);
|
||||||
|
let body = read_retained_session_attachment(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
&session_id.to_string(),
|
||||||
|
&attachment.attachment_id,
|
||||||
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.media_type, "image/png");
|
||||||
|
assert_eq!(body.data.len(), sizes[index]);
|
||||||
|
assert!(body.data.iter().all(|byte| *byte == index as u8 + 1));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
read_retained_session_attachment(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
&crate::new_session_id().to_string(),
|
||||||
|
&attachments[0].attachment_id,
|
||||||
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
RetainedAttachmentReadError::SessionMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_retained_session_attachment(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
&session_id.to_string(),
|
||||||
|
"../../metadata.json",
|
||||||
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
RetainedAttachmentReadError::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retained_reads_do_not_create_missing_aggregate() {
|
fn retained_reads_do_not_create_missing_aggregate() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -514,6 +514,13 @@ pub struct WorkerSessionHistoryRequest {
|
|||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct WorkerSessionAttachmentRequest {
|
||||||
|
pub worker_ref: WorkerRef,
|
||||||
|
pub session_id: String,
|
||||||
|
pub attachment_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||||
fn backend_id(&self) -> &str;
|
fn backend_id(&self) -> &str;
|
||||||
|
|
||||||
@@ -544,6 +551,14 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
_request: WorkerSessionAttachmentRequest,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
Err(session_store::RetainedAttachmentReadError::RetentionMissing)
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
||||||
|
|
||||||
/// Read-only restore validation performed before any live controller,
|
/// Read-only restore validation performed before any live controller,
|
||||||
@@ -780,6 +795,14 @@ impl WorkerExecutionBackendRef {
|
|||||||
self.backend.worker_session_history(request)
|
self.backend.worker_session_history(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
request: WorkerSessionAttachmentRequest,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
self.backend.worker_session_attachment(request)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn spawn_worker(
|
pub(crate) fn spawn_worker(
|
||||||
&self,
|
&self,
|
||||||
request: WorkerExecutionSpawnRequest,
|
request: WorkerExecutionSpawnRequest,
|
||||||
|
|||||||
@@ -336,6 +336,13 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
|||||||
.route(
|
.route(
|
||||||
remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENT, &["DELETE"]),
|
remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENT, &["DELETE"]),
|
||||||
delete(delete_worker_uploaded_file),
|
delete(delete_worker_uploaded_file),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
remaining_route(
|
||||||
|
runtime_api::RUNTIME_ROUTE_WORKER_SESSION_ATTACHMENT,
|
||||||
|
&["GET"],
|
||||||
|
),
|
||||||
|
get(get_worker_session_attachment),
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
@@ -1980,6 +1987,60 @@ async fn delete_worker_uploaded_file(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_worker_session_attachment(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
auth: Option<Extension<RuntimeAuthContext>>,
|
||||||
|
Path((worker_id, session_id, attachment_id)): Path<(String, String, String)>,
|
||||||
|
Query(request): Query<runtime_api::WorkerSessionRequest>,
|
||||||
|
) -> Result<Response, RuntimeHttpRestError> {
|
||||||
|
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
||||||
|
let scope = auth_workspace_scope(&state, auth.as_ref())?.ok_or_else(|| {
|
||||||
|
RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"runtime_worker_session_scope_required",
|
||||||
|
"Worker Session attachment reads require a Workspace-scoped capability",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if scope.workspace_id != request.workspace_id {
|
||||||
|
return Err(RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"runtime_worker_session_workspace_scope_mismatch",
|
||||||
|
"Worker Session Workspace scope does not match the authenticated capability",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let attachment = state
|
||||||
|
.runtime
|
||||||
|
.worker_session_attachment_scoped(&scope, &worker_ref, session_id, attachment_id)
|
||||||
|
.map_err(|error| {
|
||||||
|
use session_store::RetainedAttachmentReadError as Error;
|
||||||
|
let (status, code) = match error {
|
||||||
|
Error::RetentionMissing | Error::ActivePointerMissing => {
|
||||||
|
(StatusCode::GONE, "session_attachment_expired")
|
||||||
|
}
|
||||||
|
Error::SessionMismatch | Error::NotFound => {
|
||||||
|
(StatusCode::NOT_FOUND, "session_attachment_not_found")
|
||||||
|
}
|
||||||
|
Error::ResourceLimit => (
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"session_attachment_too_large",
|
||||||
|
),
|
||||||
|
Error::MigrationRequired | Error::CorruptLog | Error::StorageUnavailable => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"session_attachment_unavailable",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
RuntimeHttpRestError::new(status, code, error.to_string())
|
||||||
|
})?;
|
||||||
|
let content_type = axum::http::HeaderValue::from_str(&attachment.media_type).map_err(|_| {
|
||||||
|
RuntimeHttpRestError::new(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"session_attachment_media_type_invalid",
|
||||||
|
"Session attachment media type is invalid",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, content_type)], attachment.data).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
async fn stop_worker(
|
async fn stop_worker(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
@@ -2289,7 +2350,7 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
}
|
}
|
||||||
if path.ends_with("/input")
|
if path.ends_with("/input")
|
||||||
|| path.ends_with("/restore")
|
|| path.ends_with("/restore")
|
||||||
|| path.contains("/attachments")
|
|| (path.contains("/attachments") && *method != Method::GET)
|
||||||
|| path.contains("/workdir-attachments")
|
|| path.contains("/workdir-attachments")
|
||||||
{
|
{
|
||||||
return Some("workers:input");
|
return Some("workers:input");
|
||||||
@@ -3031,6 +3092,13 @@ mod tests {
|
|||||||
),
|
),
|
||||||
Some("workers:input")
|
Some("workers:input")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
required_runtime_permission(
|
||||||
|
&Method::GET,
|
||||||
|
"/v1/workers/7/sessions/session-1/attachments/attachment-1"
|
||||||
|
),
|
||||||
|
Some("workers:read")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
required_runtime_permission(&Method::POST, "/v1/workers/7/workdir-attachments"),
|
required_runtime_permission(&Method::POST, "/v1/workers/7/workdir-attachments"),
|
||||||
Some("workers:input")
|
Some("workers:input")
|
||||||
|
|||||||
@@ -1673,6 +1673,39 @@ impl Runtime {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn worker_session_attachment_scoped(
|
||||||
|
&self,
|
||||||
|
scope: &RuntimeWorkspaceScope,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
session_id: String,
|
||||||
|
attachment_id: String,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
let operation_lock = self
|
||||||
|
.worker_operation_lock(worker_ref.worker_id)
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::RetentionMissing)?;
|
||||||
|
let _operation_guard = operation_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::StorageUnavailable)?;
|
||||||
|
self.ensure_worker_in_workspace(scope, worker_ref)
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::NotFound)?;
|
||||||
|
let backend = {
|
||||||
|
let state = self
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::StorageUnavailable)?;
|
||||||
|
state
|
||||||
|
.worker(worker_ref)
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::NotFound)?;
|
||||||
|
state.execution_backend.clone()
|
||||||
|
}
|
||||||
|
.ok_or(session_store::RetainedAttachmentReadError::StorageUnavailable)?;
|
||||||
|
backend.worker_session_attachment(crate::execution::WorkerSessionAttachmentRequest {
|
||||||
|
worker_ref: worker_ref.clone(),
|
||||||
|
session_id,
|
||||||
|
attachment_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Attach a live execution through a workspace-scoped Runtime authorization context.
|
/// Attach a live execution through a workspace-scoped Runtime authorization context.
|
||||||
pub fn restore_worker_scoped(
|
pub fn restore_worker_scoped(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -186,6 +186,16 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
|
|||||||
Err(session_store::RetainedHistoryReadError::RetentionMissing)
|
Err(session_store::RetainedHistoryReadError::RetentionMissing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn retained_session_attachment(
|
||||||
|
&self,
|
||||||
|
_worker_ref: &WorkerRef,
|
||||||
|
_session_id: &str,
|
||||||
|
_attachment_id: &str,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
Err(session_store::RetainedAttachmentReadError::RetentionMissing)
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn_controller(
|
async fn spawn_controller(
|
||||||
&self,
|
&self,
|
||||||
request: WorkerExecutionSpawnRequest,
|
request: WorkerExecutionSpawnRequest,
|
||||||
@@ -1116,6 +1126,27 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn retained_session_attachment(
|
||||||
|
&self,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
let aggregate_dir = self
|
||||||
|
.worker_aggregate_dir(worker_ref)
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::RetentionMissing)?;
|
||||||
|
let worker_name = Self::runtime_worker_name_for_ref(worker_ref);
|
||||||
|
session_store::read_retained_session_attachment(
|
||||||
|
&aggregate_dir,
|
||||||
|
&worker_name,
|
||||||
|
session_id,
|
||||||
|
attachment_id,
|
||||||
|
session_store::DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn observe_workspace_prompt_projection(
|
fn observe_workspace_prompt_projection(
|
||||||
&self,
|
&self,
|
||||||
projection: worker::WorkspacePromptProjection,
|
projection: worker::WorkspacePromptProjection,
|
||||||
@@ -2412,6 +2443,18 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
request: crate::execution::WorkerSessionAttachmentRequest,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
self.factory.retained_session_attachment(
|
||||||
|
&request.worker_ref,
|
||||||
|
&request.session_id,
|
||||||
|
&request.attachment_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn observe_workspace_prompt_projection(
|
fn observe_workspace_prompt_projection(
|
||||||
&self,
|
&self,
|
||||||
projection: worker::WorkspacePromptProjection,
|
projection: worker::WorkspacePromptProjection,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
|||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
workdir = { workspace = true, features = ["http-client"] }
|
workdir = { workspace = true, features = ["http-client"] }
|
||||||
worker-runtime.workspace = true
|
worker-runtime.workspace = true
|
||||||
|
session-store.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
@@ -51,7 +52,6 @@ webauthn-rs = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
agen.workspace = true
|
agen.workspace = true
|
||||||
session-store.workspace = true
|
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
tools.workspace = true
|
tools.workspace = true
|
||||||
yoi = { path = "../yoi" }
|
yoi = { path = "../yoi" }
|
||||||
|
|||||||
@@ -770,6 +770,12 @@ pub struct WorkerProxyConnectPoint {
|
|||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeSessionAttachment {
|
||||||
|
pub media_type: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum RuntimeRegistryError {
|
pub enum RuntimeRegistryError {
|
||||||
InvalidIdentifier {
|
InvalidIdentifier {
|
||||||
@@ -1220,6 +1226,23 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
_workspace_id: &str,
|
||||||
|
worker_ref: &EmbeddedWorkerRef,
|
||||||
|
_session_id: &str,
|
||||||
|
_attachment_id: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeRegistryError> {
|
||||||
|
Err(RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id().to_string(),
|
||||||
|
code: "session_attachment_unsupported".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Session attachment reads are not supported for Worker `{}`",
|
||||||
|
worker_ref.worker_id
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn session_public_search(
|
fn session_public_search(
|
||||||
&self,
|
&self,
|
||||||
_request: runtime_api::SessionPublicSearchRequest,
|
_request: runtime_api::SessionPublicSearchRequest,
|
||||||
@@ -2096,6 +2119,27 @@ impl RuntimeRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
|
||||||
|
validate_backend_identifier("worker_id", &worker.worker_id)?;
|
||||||
|
validate_backend_identifier("session_id", session_id)?;
|
||||||
|
validate_backend_identifier("attachment_id", attachment_id)?;
|
||||||
|
let runtime = self.runtime(&worker.runtime_id)?;
|
||||||
|
let worker_ref =
|
||||||
|
EmbeddedWorkerRef::new(EmbeddedWorkerId::parse(&worker.worker_id).ok_or_else(
|
||||||
|
|| RuntimeRegistryError::UnknownWorker {
|
||||||
|
worker: worker.clone(),
|
||||||
|
},
|
||||||
|
)?);
|
||||||
|
runtime.worker_session_attachment(workspace_id, &worker_ref, session_id, attachment_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn session_public_search(
|
pub fn session_public_search(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -3017,6 +3061,46 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_ref: &EmbeddedWorkerRef,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeRegistryError> {
|
||||||
|
let scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend");
|
||||||
|
self.runtime
|
||||||
|
.worker_session_attachment_scoped(
|
||||||
|
&scope,
|
||||||
|
worker_ref,
|
||||||
|
session_id.to_string(),
|
||||||
|
attachment_id.to_string(),
|
||||||
|
)
|
||||||
|
.map(|attachment| RuntimeSessionAttachment {
|
||||||
|
media_type: attachment.media_type,
|
||||||
|
data: attachment.data,
|
||||||
|
})
|
||||||
|
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: match error {
|
||||||
|
session_store::RetainedAttachmentReadError::RetentionMissing
|
||||||
|
| session_store::RetainedAttachmentReadError::ActivePointerMissing => {
|
||||||
|
"session_attachment_expired"
|
||||||
|
}
|
||||||
|
session_store::RetainedAttachmentReadError::SessionMismatch
|
||||||
|
| session_store::RetainedAttachmentReadError::NotFound => {
|
||||||
|
"session_attachment_not_found"
|
||||||
|
}
|
||||||
|
session_store::RetainedAttachmentReadError::ResourceLimit => {
|
||||||
|
"session_attachment_too_large"
|
||||||
|
}
|
||||||
|
_ => "session_attachment_unavailable",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn session_public_search(
|
fn session_public_search(
|
||||||
&self,
|
&self,
|
||||||
request: runtime_api::SessionPublicSearchRequest,
|
request: runtime_api::SessionPublicSearchRequest,
|
||||||
@@ -3732,7 +3816,7 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
|
|||||||
}
|
}
|
||||||
if path.ends_with("/input")
|
if path.ends_with("/input")
|
||||||
|| path.ends_with("/restore")
|
|| path.ends_with("/restore")
|
||||||
|| path.contains("/attachments")
|
|| (path.contains("/attachments") && method != "GET")
|
||||||
|| path.contains("/workdir-attachments")
|
|| path.contains("/workdir-attachments")
|
||||||
{
|
{
|
||||||
return "workers:input";
|
return "workers:input";
|
||||||
@@ -4059,6 +4143,64 @@ impl RemoteWorkerRuntime {
|
|||||||
self.send_json(path, "GET", &[], self.http.get(self.endpoint(path)))
|
self.send_json(path, "GET", &[], self.http.get(self.endpoint(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_session_attachment(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeDiagnostic> {
|
||||||
|
let runtime_id = self.runtime_id.clone();
|
||||||
|
let workspace_id = self.workspace_id.clone();
|
||||||
|
let bearer_token = self.bearer_token.clone();
|
||||||
|
let capability_token = match &self.workspace_authorization {
|
||||||
|
Some(authorization) => Some(authorization.issue(
|
||||||
|
"GET",
|
||||||
|
path,
|
||||||
|
workspace_runtime_operation("GET", path),
|
||||||
|
worker_id_from_remote_path(path).as_deref(),
|
||||||
|
&[],
|
||||||
|
)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let request = self.http.get(self.endpoint(path));
|
||||||
|
run_blocking_http(move || {
|
||||||
|
let mut request = request.header(RUNTIME_WORKSPACE_SCOPE_HEADER, &workspace_id);
|
||||||
|
if let Some(token) = capability_token.as_deref().or(bearer_token.as_deref()) {
|
||||||
|
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||||
|
}
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.map_err(|error| remote_reqwest_diagnostic(&runtime_id, error))?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(remote_http_status_diagnostic(&runtime_id, status, response));
|
||||||
|
}
|
||||||
|
let media_type = response
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
let mut data = Vec::new();
|
||||||
|
response
|
||||||
|
.take((MAX_REMOTE_RUNTIME_RESPONSE_BYTES + 1) as u64)
|
||||||
|
.read_to_end(&mut data)
|
||||||
|
.map_err(|_| {
|
||||||
|
diagnostic(
|
||||||
|
"remote_runtime_response_read_failed",
|
||||||
|
HostDiagnosticSeverity::Error,
|
||||||
|
"Remote Runtime attachment response could not be read".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if data.len() > MAX_REMOTE_RUNTIME_RESPONSE_BYTES {
|
||||||
|
return Err(diagnostic(
|
||||||
|
"remote_runtime_response_too_large",
|
||||||
|
HostDiagnosticSeverity::Error,
|
||||||
|
"Remote Runtime attachment response exceeded the allowed size".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(RuntimeSessionAttachment { media_type, data })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn post_json<B, T>(&self, path: &str, body: &B) -> Result<T, RuntimeDiagnostic>
|
fn post_json<B, T>(&self, path: &str, body: &B) -> Result<T, RuntimeDiagnostic>
|
||||||
where
|
where
|
||||||
B: Serialize + ?Sized,
|
B: Serialize + ?Sized,
|
||||||
@@ -5093,6 +5235,29 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
.map_err(|diagnostic| diagnostic.message)
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_ref: &EmbeddedWorkerRef,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeRegistryError> {
|
||||||
|
let path = format!(
|
||||||
|
"/v1/workers/{}/sessions/{}/attachments/{}?workspace_id={}",
|
||||||
|
url_path_segment_encode(&worker_ref.worker_id.to_string()),
|
||||||
|
url_path_segment_encode(session_id),
|
||||||
|
url_path_segment_encode(attachment_id),
|
||||||
|
url_query_value_encode(workspace_id),
|
||||||
|
);
|
||||||
|
self.get_session_attachment(&path).map_err(|diagnostic| {
|
||||||
|
RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: diagnostic.code,
|
||||||
|
message: diagnostic.message,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn session_public_search(
|
fn session_public_search(
|
||||||
&self,
|
&self,
|
||||||
request: runtime_api::SessionPublicSearchRequest,
|
request: runtime_api::SessionPublicSearchRequest,
|
||||||
@@ -7648,6 +7813,13 @@ mod tests {
|
|||||||
),
|
),
|
||||||
"workers:input"
|
"workers:input"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
workspace_runtime_operation(
|
||||||
|
"GET",
|
||||||
|
&format!("/v1/workers/{worker_id}/sessions/session-1/attachments/attachment-1")
|
||||||
|
),
|
||||||
|
"workers:read"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace_runtime_operation("POST", SSH_HOST_KEY_PROBE_PATH),
|
workspace_runtime_operation("POST", SSH_HOST_KEY_PROBE_PATH),
|
||||||
SSH_HOST_KEY_PROBE_OPERATION
|
SSH_HOST_KEY_PROBE_OPERATION
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use std::sync::{Arc, Mutex, RwLock, Weak};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::extract::ws::{CloseFrame, Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{CloseFrame, Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State};
|
use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State};
|
||||||
|
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, LOCATION, ORIGIN};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use axum::http::header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH};
|
use axum::http::header::{ETAG, IF_NONE_MATCH};
|
||||||
use axum::http::header::{CONTENT_TYPE, LOCATION, ORIGIN};
|
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
|
||||||
use axum::middleware::{self, Next};
|
use axum::middleware::{self, Next};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
@@ -10268,6 +10268,72 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_worker_session_attachment(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath((workspace_id, runtime_id, worker_id, session_id, attachment_id)): AxumPath<(
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
)>,
|
||||||
|
Extension(_actor): Extension<RequestActor>,
|
||||||
|
) -> Response {
|
||||||
|
if workspace_id != api.config.workspace_id {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({"code": "workspace_not_found"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
let worker = RuntimeWorkerRef::new(runtime_id, worker_id);
|
||||||
|
match api
|
||||||
|
.runtime
|
||||||
|
.worker_session_attachment(&workspace_id, &worker, &session_id, &attachment_id)
|
||||||
|
{
|
||||||
|
Ok(attachment) => {
|
||||||
|
let Ok(content_type) = HeaderValue::from_str(&attachment.media_type) else {
|
||||||
|
return (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(serde_json::json!({"code": "session_attachment_media_type_invalid"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(CONTENT_TYPE, content_type),
|
||||||
|
(
|
||||||
|
CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("private, max-age=3600"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
attachment.data,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(crate::hosts::RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
code, message, ..
|
||||||
|
}) => {
|
||||||
|
let status = match code.as_str() {
|
||||||
|
"session_attachment_expired" => StatusCode::GONE,
|
||||||
|
"session_attachment_not_found" => StatusCode::NOT_FOUND,
|
||||||
|
"session_attachment_too_large" => StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
Json(serde_json::json!({"code": code, "message": message})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(_) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({"code": "session_attachment_not_found"})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_inner_router(api: WorkspaceApi) -> Router {
|
fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||||
let contract_service = ServerApiContractService::Workspace(api.clone());
|
let contract_service = ServerApiContractService::Workspace(api.clone());
|
||||||
let auth = generated_auth_contract_router(contract_service.clone());
|
let auth = generated_auth_contract_router(contract_service.clone());
|
||||||
@@ -10296,6 +10362,10 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
_,
|
_,
|
||||||
_,
|
_,
|
||||||
>(workspace, scoped_worker_protocol_ws)
|
>(workspace, scoped_worker_protocol_ws)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/sessions/{session_id}/attachments/{attachment_id}",
|
||||||
|
get(scoped_worker_session_attachment),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/workers/self/backend-job-result",
|
"/api/w/{workspace_id}/workers/self/backend-job-result",
|
||||||
post(scoped_submit_backend_job_result),
|
post(scoped_submit_backend_job_result),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -129,12 +129,12 @@ export type SessionMessageRole = "user" | "assistant";
|
|||||||
|
|
||||||
export type SessionContentPart = { "kind": "text", text: string, } | { "kind": "refusal", refusal: string, };
|
export type SessionContentPart = { "kind": "text", text: string, } | { "kind": "refusal", refusal: string, };
|
||||||
|
|
||||||
export type SessionToolAttachment = { media_type: string,
|
export type SessionToolAttachment = {
|
||||||
/**
|
/**
|
||||||
* Base64-encoded durable attachment body. Public snapshots preserve the
|
* Stable, bounded identity derived from the owning Session entry, attachment
|
||||||
* committed multimodal value instead of replacing it with placeholder text.
|
* position, and immutable image bytes. It is a locator, not a bearer secret.
|
||||||
*/
|
*/
|
||||||
data_base64: string, };
|
attachment_id: string, media_type: string, byte_len: number, };
|
||||||
|
|
||||||
export type RunYieldReason = "compaction";
|
export type RunYieldReason = "compaction";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SessionToolAttachment } from "#lib/generated/protocol.ts";
|
||||||
|
import {
|
||||||
|
ConsoleAttachmentFetchError,
|
||||||
|
loadConsoleAttachment,
|
||||||
|
} from "./attachment-loader.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
attachment: SessionToolAttachment;
|
||||||
|
url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { attachment, url }: Props = $props();
|
||||||
|
let root: HTMLElement | null = $state(null);
|
||||||
|
let objectUrl = $state<string | null>(null);
|
||||||
|
let disposed = false;
|
||||||
|
let status = $state<"idle" | "loading" | "ready" | "missing" | "expired" | "unauthorized" | "network" | "failed">("idle");
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (!url || status === "loading" || status === "ready") return;
|
||||||
|
status = "loading";
|
||||||
|
try {
|
||||||
|
const blob = await loadConsoleAttachment(url);
|
||||||
|
if (disposed) return;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
status = "ready";
|
||||||
|
} catch (error) {
|
||||||
|
if (disposed) return;
|
||||||
|
status = error instanceof ConsoleAttachmentFetchError ? error.kind : "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(): string {
|
||||||
|
switch (status) {
|
||||||
|
case "idle": return url ? "Image waiting to load" : "Image Session is unavailable";
|
||||||
|
case "loading": return "Loading image…";
|
||||||
|
case "missing": return "Image is missing";
|
||||||
|
case "expired": return "Image retention has expired";
|
||||||
|
case "unauthorized": return "Image access was denied";
|
||||||
|
case "network": return "Image network request failed";
|
||||||
|
case "failed": return "Image could not be loaded";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!root || !url || status !== "idle") return;
|
||||||
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
|
void load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
observer.disconnect();
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
}, { rootMargin: "160px" });
|
||||||
|
observer.observe(root);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => () => {
|
||||||
|
disposed = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="console-attachment" bind:this={root} data-attachment-id={attachment.attachment_id}>
|
||||||
|
{#if status === "ready" && objectUrl}
|
||||||
|
<img src={objectUrl} alt="Tool output attachment" />
|
||||||
|
{:else}
|
||||||
|
<span>{statusText()}</span>
|
||||||
|
{#if url && ["missing", "expired", "unauthorized", "network", "failed"].includes(status)}
|
||||||
|
<button type="button" onclick={() => void load()}>Retry</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.console-attachment {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 52rem);
|
||||||
|
max-height: 42rem;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AnsiText from '#lib/workspace/console/AnsiText.svelte';
|
import AnsiText from '#lib/workspace/console/AnsiText.svelte';
|
||||||
|
import ConsoleAttachmentImage from '#lib/workspace/console/ConsoleAttachmentImage.svelte';
|
||||||
import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
|
import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
|
||||||
|
import type { SessionToolAttachment } from '#lib/generated/protocol.ts';
|
||||||
import type { ConsoleLine } from '#lib/workspace/console/model.ts';
|
import type { ConsoleLine } from '#lib/workspace/console/model.ts';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: ConsoleLine;
|
item: ConsoleLine;
|
||||||
|
attachmentUrl?: (attachment: SessionToolAttachment) => string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { item }: Props = $props();
|
let { item, attachmentUrl = () => null }: Props = $props();
|
||||||
let detailOpen = $state(false);
|
let detailOpen = $state(false);
|
||||||
let nowMs = $state(Date.now());
|
let nowMs = $state(Date.now());
|
||||||
|
|
||||||
@@ -140,6 +143,13 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="console-plain-text">{item.body || '—'}</p>
|
<p class="console-plain-text">{item.body || '—'}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if item.attachments?.length}
|
||||||
|
<div class="console-attachments">
|
||||||
|
{#each item.attachments as attachment (attachment.attachment_id)}
|
||||||
|
<ConsoleAttachmentImage {attachment} url={attachmentUrl(attachment)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if item.diff}
|
{#if item.diff}
|
||||||
<div class="console-diff" role="group" aria-label="Edit diff">
|
<div class="console-diff" role="group" aria-label="Edit diff">
|
||||||
{#each item.diff as diffLine}
|
{#each item.diff as diffLine}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
clearConsoleAttachmentCache,
|
||||||
|
ConsoleAttachmentFetchError,
|
||||||
|
loadConsoleAttachment,
|
||||||
|
} from "./attachment-loader.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStrictEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error("expected values to be strictly identical");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("console attachment loads are lazy-callable and deduplicate in-flight and successful fetches", async () => {
|
||||||
|
clearConsoleAttachmentCache();
|
||||||
|
let calls = 0;
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => release = resolve);
|
||||||
|
const fetcher = (async () => {
|
||||||
|
calls += 1;
|
||||||
|
await gate;
|
||||||
|
return new Response(new Uint8Array([1, 2, 3]), {
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
assertEquals(calls, 0);
|
||||||
|
const first = loadConsoleAttachment("/attachment/a", fetcher);
|
||||||
|
const second = loadConsoleAttachment("/attachment/a", fetcher);
|
||||||
|
assertStrictEquals(first, second);
|
||||||
|
assertEquals(calls, 1);
|
||||||
|
release();
|
||||||
|
const blob = await first;
|
||||||
|
assertEquals(blob.size, 3);
|
||||||
|
assertStrictEquals(
|
||||||
|
await loadConsoleAttachment("/attachment/a", fetcher),
|
||||||
|
blob,
|
||||||
|
);
|
||||||
|
assertEquals(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("console attachment failures stay local and classify retention and authorization", async () => {
|
||||||
|
clearConsoleAttachmentCache();
|
||||||
|
for (
|
||||||
|
const [status, kind] of [[404, "missing"], [410, "expired"], [
|
||||||
|
403,
|
||||||
|
"unauthorized",
|
||||||
|
]] as const
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await loadConsoleAttachment(
|
||||||
|
`/attachment/${status}`,
|
||||||
|
(async () => new Response(null, { status })) as typeof fetch,
|
||||||
|
);
|
||||||
|
throw new Error("expected failure");
|
||||||
|
} catch (error) {
|
||||||
|
assertEquals(
|
||||||
|
error instanceof ConsoleAttachmentFetchError && error.kind,
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export type ConsoleAttachmentFetchErrorKind =
|
||||||
|
| "missing"
|
||||||
|
| "expired"
|
||||||
|
| "unauthorized"
|
||||||
|
| "network"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export class ConsoleAttachmentFetchError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly kind: ConsoleAttachmentFetchErrorKind,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ConsoleAttachmentFetchError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successful = new Map<string, Blob>();
|
||||||
|
const inFlight = new Map<string, Promise<Blob>>();
|
||||||
|
|
||||||
|
export function loadConsoleAttachment(
|
||||||
|
url: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const cached = successful.get(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
const pending = inFlight.get(url);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetcher(url, { credentials: "same-origin" });
|
||||||
|
} catch (error) {
|
||||||
|
throw new ConsoleAttachmentFetchError(
|
||||||
|
"network",
|
||||||
|
error instanceof Error ? error.message : "Network request failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const kind: ConsoleAttachmentFetchErrorKind = response.status === 404
|
||||||
|
? "missing"
|
||||||
|
: response.status === 410
|
||||||
|
? "expired"
|
||||||
|
: response.status === 401 || response.status === 403
|
||||||
|
? "unauthorized"
|
||||||
|
: "failed";
|
||||||
|
throw new ConsoleAttachmentFetchError(kind, `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
successful.set(url, blob);
|
||||||
|
return blob;
|
||||||
|
})().finally(() => inFlight.delete(url));
|
||||||
|
inFlight.set(url, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearConsoleAttachmentCache(): void {
|
||||||
|
successful.clear();
|
||||||
|
inFlight.clear();
|
||||||
|
}
|
||||||
@@ -3741,6 +3741,11 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
|||||||
summary: "read",
|
summary: "read",
|
||||||
content: "contents",
|
content: "contents",
|
||||||
is_error: false,
|
is_error: false,
|
||||||
|
attachments: [{
|
||||||
|
attachment_id: "attachment-1",
|
||||||
|
media_type: "image/png",
|
||||||
|
byte_len: 5_735_340,
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3753,6 +3758,15 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
|||||||
);
|
);
|
||||||
assertEquals(lines[0].entryId, "tool-result-entry");
|
assertEquals(lines[0].entryId, "tool-result-entry");
|
||||||
assertEquals(lines[0].toolCall?.state, "done");
|
assertEquals(lines[0].toolCall?.state, "done");
|
||||||
|
assertEquals(lines[0].attachments, [{
|
||||||
|
attachment_id: "attachment-1",
|
||||||
|
media_type: "image/png",
|
||||||
|
byte_len: 5_735_340,
|
||||||
|
}]);
|
||||||
|
assertEquals(
|
||||||
|
projectConsoleLines(lines, "overview")[0].attachments,
|
||||||
|
lines[0].attachments,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function readHistory(ids: string[], summary: string): ConsoleLine[] {
|
function readHistory(ids: string[], summary: string): ConsoleLine[] {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ReasoningConfig,
|
ReasoningConfig,
|
||||||
Segment,
|
Segment,
|
||||||
SessionSnapshotEntry,
|
SessionSnapshotEntry,
|
||||||
|
SessionToolAttachment,
|
||||||
WorkerState,
|
WorkerState,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
WorkerStatus,
|
WorkerStatus,
|
||||||
@@ -63,6 +64,7 @@ type ToolCallView = {
|
|||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
command?: CommandSnapshot;
|
command?: CommandSnapshot;
|
||||||
|
attachments?: SessionToolAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConsoleDiffLine = {
|
export type ConsoleDiffLine = {
|
||||||
@@ -99,6 +101,7 @@ export type ConsoleLine = {
|
|||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
toolCall?: ToolCallView;
|
toolCall?: ToolCallView;
|
||||||
|
attachments?: SessionToolAttachment[];
|
||||||
/** Number of calls represented by a lower-level aggregate line. */
|
/** Number of calls represented by a lower-level aggregate line. */
|
||||||
toolCallCount?: number;
|
toolCallCount?: number;
|
||||||
/** Original Read rows, retained so history can reconcile before aggregation. */
|
/** Original Read rows, retained so history can reconcile before aggregation. */
|
||||||
@@ -593,7 +596,12 @@ export function projectOverviewLines(lines: ConsoleLine[]): ConsoleLine[] {
|
|||||||
if (line.systemItemKind === "task_reminder") continue;
|
if (line.systemItemKind === "task_reminder") continue;
|
||||||
if (isOverviewThinkingLine(line)) continue;
|
if (isOverviewThinkingLine(line)) continue;
|
||||||
if (line.kind === "tool" && line.toolCall) {
|
if (line.kind === "tool" && line.toolCall) {
|
||||||
toolGroup.push(line);
|
if (line.attachments?.length) {
|
||||||
|
flushTools();
|
||||||
|
overview.push(line);
|
||||||
|
} else {
|
||||||
|
toolGroup.push(line);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
flushTools();
|
flushTools();
|
||||||
@@ -1634,7 +1642,7 @@ function attachToolResult(
|
|||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
id: string,
|
id: string,
|
||||||
result: Pick<ToolCallView, "summary" | "output" | "isError">,
|
result: Pick<ToolCallView, "summary" | "output" | "isError" | "attachments">,
|
||||||
): void {
|
): void {
|
||||||
const index = findToolCallLineIndex(projection, id);
|
const index = findToolCallLineIndex(projection, id);
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
@@ -1711,6 +1719,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
|||||||
toolStatus: toolCallStatus(toolCall),
|
toolStatus: toolCallStatus(toolCall),
|
||||||
detail: toolCallDetail(toolCall),
|
detail: toolCallDetail(toolCall),
|
||||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||||
|
attachments: toolCall.attachments,
|
||||||
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
||||||
error: toolCall.state === "error" || commandError,
|
error: toolCall.state === "error" || commandError,
|
||||||
};
|
};
|
||||||
@@ -1816,6 +1825,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
const hasError = calls.some((call) => call.state === "error");
|
const hasError = calls.some((call) => call.state === "error");
|
||||||
const paths = calls.map(readPath);
|
const paths = calls.map(readPath);
|
||||||
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
||||||
|
const attachments = group.flatMap((line) => line.attachments ?? []);
|
||||||
const body = compactLines([
|
const body = compactLines([
|
||||||
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
||||||
inProgress && paths.length > visiblePaths.length
|
inProgress && paths.length > visiblePaths.length
|
||||||
@@ -1835,8 +1845,10 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
source: "event",
|
source: "event",
|
||||||
streaming: inProgress,
|
streaming: inProgress,
|
||||||
error: hasError,
|
error: hasError,
|
||||||
|
attachments,
|
||||||
toolCall: {
|
toolCall: {
|
||||||
...calls[0]!,
|
...calls[0]!,
|
||||||
|
attachments,
|
||||||
state: hasError ? "error" : inProgress ? "running" : "done",
|
state: hasError ? "error" : inProgress ? "running" : "done",
|
||||||
isError: hasError,
|
isError: hasError,
|
||||||
},
|
},
|
||||||
@@ -2361,6 +2373,7 @@ function applySessionEntry(
|
|||||||
summary: value["summary"],
|
summary: value["summary"],
|
||||||
content: value["content"],
|
content: value["content"],
|
||||||
is_error: value["is_error"],
|
is_error: value["is_error"],
|
||||||
|
attachments: value["attachments"],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "system_item": {
|
case "system_item": {
|
||||||
@@ -2509,6 +2522,10 @@ function applyLoggedItem(
|
|||||||
summary: stringField(item, "summary") ?? "",
|
summary: stringField(item, "summary") ?? "",
|
||||||
output: stringField(item, "content"),
|
output: stringField(item, "content"),
|
||||||
isError: item["is_error"] === true,
|
isError: item["is_error"] === true,
|
||||||
|
attachments: arrayField(
|
||||||
|
item,
|
||||||
|
"attachments",
|
||||||
|
) as SessionToolAttachment[],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
RewindTarget,
|
RewindTarget,
|
||||||
Segment,
|
Segment,
|
||||||
SessionHistoryPage,
|
SessionHistoryPage,
|
||||||
|
SessionToolAttachment,
|
||||||
} from "#lib/generated/protocol.ts";
|
} from "#lib/generated/protocol.ts";
|
||||||
import {
|
import {
|
||||||
MAX_FILES_PER_SUBMISSION,
|
MAX_FILES_PER_SUBMISSION,
|
||||||
@@ -219,6 +220,17 @@
|
|||||||
const selectedHistory = $derived(
|
const selectedHistory = $derived(
|
||||||
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
|
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
|
||||||
);
|
);
|
||||||
|
const selectedAttachmentSessionId = $derived(
|
||||||
|
selectedWorkerView.sessionId ?? selectedHistory.sessionId,
|
||||||
|
);
|
||||||
|
function attachmentUrl(attachment: SessionToolAttachment): string | null {
|
||||||
|
if (!runtimeId || !workerId || !selectedAttachmentSessionId) return null;
|
||||||
|
return workerApiPath(
|
||||||
|
`runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}` +
|
||||||
|
`/sessions/${encodeURIComponent(selectedAttachmentSessionId)}` +
|
||||||
|
`/attachments/${encodeURIComponent(attachment.attachment_id)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const committedHistoryLines = $derived(
|
const committedHistoryLines = $derived(
|
||||||
projectSessionHistoryEntries(
|
projectSessionHistoryEntries(
|
||||||
historyEntries(selectedHistory),
|
historyEntries(selectedHistory),
|
||||||
@@ -1755,7 +1767,7 @@
|
|||||||
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
|
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
|
||||||
<ol class="console-log">
|
<ol class="console-log">
|
||||||
{#each lines as item (item.id)}
|
{#each lines as item (item.id)}
|
||||||
<ConsoleLineItem {item} />
|
<ConsoleLineItem {item} {attachmentUrl} />
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user