chore: merge develop while preserving console interactions

This commit is contained in:
2026-09-30 12:17:49 +09:00
83 changed files with 7969 additions and 949 deletions
Generated
+3
View File
@@ -6383,6 +6383,7 @@ dependencies = [
name = "yoi-workspace-server" name = "yoi-workspace-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"agen",
"async-trait", "async-trait",
"axum", "axum",
"chrono", "chrono",
@@ -6410,6 +6411,7 @@ dependencies = [
"tokio", "tokio",
"tokio-tungstenite 0.29.0", "tokio-tungstenite 0.29.0",
"toml", "toml",
"tools",
"tower", "tower",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -6421,6 +6423,7 @@ dependencies = [
"workdir", "workdir",
"worker", "worker",
"worker-runtime", "worker-runtime",
"yoi",
"zeroize", "zeroize",
] ]
+99 -1
View File
@@ -15,7 +15,8 @@ use thiserror::Error;
pub use glob::run_glob; pub use glob::run_glob;
pub use local::{ pub use local::{
resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_stat, run_write, resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_read_bytes,
run_read_bytes_bounded, run_stat, run_write,
}; };
pub use operation::*; pub use operation::*;
pub use search::run_grep; pub use search::run_grep;
@@ -569,6 +570,103 @@ mod tests {
} }
} }
#[test]
fn bounded_byte_reads_preserve_binary_ranges_and_fence_versions() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
let original = b"\x89PNG\r\n\x1a\n\0line\n\xfftail";
std::fs::write(root.join("image.bin"), original).unwrap();
let access = RootAccess(root.clone());
let limits = BoundedReadLimits::new(1024, 8).unwrap();
let first = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 0,
max_bytes: 8,
expected_hash: None,
},
&access,
limits,
)
.unwrap();
assert_eq!(first.bytes, original[..8]);
assert_eq!(first.total_bytes, original.len() as u64);
assert!(!first.eof);
let second = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 8,
max_bytes: 8,
expected_hash: Some(first.content_hash),
},
&access,
limits,
)
.unwrap();
assert_eq!(second.bytes, original[8..16]);
assert_eq!(second.content_hash, first.content_hash);
std::fs::write(root.join("image.bin"), b"different bytes long!").unwrap();
let error = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 16,
max_bytes: 8,
expected_hash: Some(first.content_hash),
},
&access,
limits,
)
.expect_err("a later chunk must not cross file versions");
assert!(matches!(error, FsError::Conflict(_)));
}
#[test]
fn bounded_byte_reads_reject_request_and_source_limit_overflow() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
std::fs::write(root.join("image.bin"), [0_u8; 17]).unwrap();
let access = RootAccess(root.clone());
let limits = BoundedReadLimits::new(16, 8).unwrap();
let response_error = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 0,
max_bytes: 9,
expected_hash: None,
},
&access,
limits,
)
.expect_err("requests above the response ceiling must be rejected");
assert!(
matches!(response_error, FsError::InvalidArgument(message) if message.contains("8 bytes"))
);
let source_error = run_read_bytes_bounded(
&root,
ReadBytesRequest {
path: FsPath::new("image.bin").unwrap(),
offset: 0,
max_bytes: 8,
expected_hash: None,
},
&access,
limits,
)
.expect_err("sources above the provider ceiling must be rejected");
assert!(
matches!(source_error, FsError::InvalidArgument(message) if message.contains("provider limit 16"))
);
}
#[test] #[test]
fn cancellation_stops_a_read_already_in_progress() { fn cancellation_stops_a_read_already_in_progress() {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+134 -2
View File
@@ -7,8 +7,9 @@ use sha2::{Digest, Sha256};
use crate::{ use crate::{
AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind,
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult, FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest,
StatRequest, StatResult, WriteRequest, WriteResult, direct_symlink, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
direct_symlink,
}; };
const READ_BUFFER_BYTES: usize = 16 * 1024; const READ_BUFFER_BYTES: usize = 16 * 1024;
@@ -195,6 +196,137 @@ fn run_read_with_limits(
}) })
} }
pub fn run_read_bytes(
root: &Path,
request: ReadBytesRequest,
access: &dyn FsAccessPolicy,
) -> Result<ReadBytesResult, FsError> {
run_read_bytes_with_limits(root, request, access, None)
}
pub fn run_read_bytes_bounded(
root: &Path,
request: ReadBytesRequest,
access: &dyn FsAccessPolicy,
limits: BoundedReadLimits,
) -> Result<ReadBytesResult, FsError> {
run_read_bytes_with_limits(root, request, access, Some(limits))
}
fn run_read_bytes_with_limits(
root: &Path,
request: ReadBytesRequest,
access: &dyn FsAccessPolicy,
limits: Option<BoundedReadLimits>,
) -> Result<ReadBytesResult, FsError> {
let logical = request.path;
if request.max_bytes == 0 {
return Err(FsError::InvalidArgument(
"byte read max_bytes must be greater than zero".to_string(),
));
}
if let Some(limits) = limits
&& request.max_bytes > limits.max_response_bytes
{
return Err(FsError::InvalidArgument(format!(
"byte read response limit is {} bytes",
limits.max_response_bytes
)));
}
access
.check_cancelled()
.map_err(|error| map_io(&logical, error))?;
let path = resolve(root, &logical)?;
let target = require_access(&path, &logical, access, false, false)?;
let file = access
.open_read_file(&path, &target)
.map_err(|error| map_io(&logical, error))?;
let metadata = file.metadata().map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
}
if !metadata.is_file() {
return Err(FsError::InvalidArgument(
"byte read source must be a regular file".to_string(),
));
}
if let Some(limits) = limits
&& metadata.len() > limits.max_source_bytes
{
return Err(FsError::InvalidArgument(format!(
"byte read source exceeds provider limit {}",
limits.max_source_bytes
)));
}
let mut bytes = Vec::with_capacity(request.max_bytes.min(READ_BUFFER_BYTES));
let mut source_bytes = 0_u64;
let mut hasher = Sha256::new();
let mut reader = BufReader::with_capacity(READ_BUFFER_BYTES, file);
let mut buffer = [0_u8; READ_BUFFER_BYTES];
loop {
access
.check_cancelled()
.map_err(|error| map_io(&logical, error))?;
let read_limit = limits
.map(|limits| {
limits
.max_source_bytes
.saturating_sub(source_bytes)
.saturating_add(1)
.min(READ_BUFFER_BYTES as u64) as usize
})
.unwrap_or(READ_BUFFER_BYTES);
let read = reader
.read(&mut buffer[..read_limit])
.map_err(|error| map_io(&logical, error))?;
if read == 0 {
break;
}
let chunk_start = source_bytes;
source_bytes = source_bytes.saturating_add(read as u64);
if let Some(limits) = limits
&& source_bytes > limits.max_source_bytes
{
return Err(FsError::InvalidArgument(format!(
"byte read source exceeds provider limit {}",
limits.max_source_bytes
)));
}
hasher.update(&buffer[..read]);
if bytes.len() < request.max_bytes && source_bytes > request.offset {
let start = request.offset.saturating_sub(chunk_start).min(read as u64) as usize;
let retained = (request.max_bytes - bytes.len()).min(read - start);
bytes.extend_from_slice(&buffer[start..start + retained]);
}
}
let content_hash: ContentHash = hasher.finalize().into();
if request
.expected_hash
.is_some_and(|expected| expected != content_hash)
{
return Err(FsError::Conflict(logical.as_str().to_string()));
}
if request.offset > source_bytes {
return Err(FsError::InvalidArgument(format!(
"byte offset {} exceeds file size {source_bytes}",
request.offset
)));
}
let eof = request.offset.saturating_add(bytes.len() as u64) == source_bytes;
Ok(ReadBytesResult {
path: logical,
bytes,
offset: request.offset,
total_bytes: source_bytes,
content_hash,
eof,
})
}
pub fn run_write( pub fn run_write(
root: &Path, root: &Path,
request: WriteRequest, request: WriteRequest,
+30
View File
@@ -207,6 +207,36 @@ pub struct ReadResult {
pub truncated: bool, pub truncated: bool,
} }
/// Binary-safe byte range read with an optional whole-file version fence.
///
/// Unlike [`ReadRequest`], offsets and limits are bytes rather than lines. The
/// provider still hashes the complete bounded source so callers can reject
/// chunks from different file versions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReadBytesRequest {
pub path: FsPath,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub offset: u64,
#[schemars(range(min = 1, max = 9_007_199_254_740_991_usize))]
pub max_bytes: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_hash: Option<ContentHash>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReadBytesResult {
pub path: FsPath,
pub bytes: Vec<u8>,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub offset: u64,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub total_bytes: u64,
pub content_hash: ContentHash,
pub eof: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WriteRequest { pub struct WriteRequest {
+61 -1
View File
@@ -838,6 +838,56 @@ pub struct SessionSnapshot {
pub entries: Vec<SessionSnapshotEntry>, pub entries: Vec<SessionSnapshotEntry>,
} }
/// One user-visible conversation turn. A turn starts at a canonical user entry
/// and contains every public committed entry until the next user entry.
/// `turn_id` is the stable `entry_id` of that first user entry.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SessionConversationTurn {
pub turn_id: String,
pub entries: Vec<SessionSnapshotEntry>,
}
/// The immediate parent lineage and the stable real-user boundary adopted from it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SessionHistoryLineageBoundary {
pub lineage_id: String,
/// Canonical public form of the final user-visible turn at the exact
/// provider boundary. Entries after that boundary are excluded.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adopted_through_turn: Option<SessionConversationTurn>,
}
/// Bounded backward page over the adopted conversation lineage.
///
/// Turns remain chronological within a page. `next_cursor` is an opaque,
/// identity-bound boundary for the page immediately preceding `turns`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SessionHistoryPage {
pub session_id: String,
/// Stable identity of the adopted active lineage used to fence stale pages.
pub lineage_id: String,
/// Older lineage identities that the active lineage fully adopted through
/// one or more consecutive Compact rotations. Clients may retain already
/// loaded turns when their prior lineage appears here, while replacing the
/// cursor with this page's boundary.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compact_ancestor_lineage_ids: Vec<String>,
/// Immediate parent relationship used to retain only the adopted prefix
/// across fork/rewind refreshes that have no newest-page overlap.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_lineage: Option<SessionHistoryLineageBoundary>,
pub turns: Vec<SessionConversationTurn>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub has_more: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[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))]
@@ -963,8 +1013,17 @@ pub enum Event {
/// activated for a turn. Broadcast to every subscribed client so TUI / GUI /// activated for a turn. Broadcast to every subscribed client so TUI / GUI
/// instances show the same user line that reconnect snapshots replay. /// instances show the same user line that reconnect snapshots replay.
UserMessage { UserMessage {
/// Stable durable history identity. Older mixed-version producers omit it.
#[serde(default, skip_serializing_if = "Option::is_none")]
entry_id: Option<String>,
segments: Vec<Segment>, segments: Vec<Segment>,
}, },
/// A canonical assistant/tool entry has committed to durable history.
/// Streaming events remain the in-flight authority; this event supplies the
/// stable identity used to reconcile that live block with paged history.
SessionEntryCommitted {
entry: SessionSnapshotEntry,
},
/// One agent-injected system item committed to history. /// One agent-injected system item committed to history.
/// ///
/// Carries the JSON form of `session_store::SystemItem`. Covers /// Carries the JSON form of `session_store::SystemItem`. Covers
@@ -2765,6 +2824,7 @@ mod tests {
#[test] #[test]
fn event_user_message_roundtrip() { fn event_user_message_roundtrip() {
let event = Event::UserMessage { let event = Event::UserMessage {
entry_id: Some("entry-1".to_string()),
segments: vec![Segment::text("hello 世界")], segments: vec![Segment::text("hello 世界")],
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
@@ -2775,7 +2835,7 @@ mod tests {
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Event::UserMessage { segments } => { Event::UserMessage { segments, .. } => {
assert_eq!(segments.len(), 1); assert_eq!(segments.len(), 1);
match &segments[0] { match &segments[0] {
Segment::Text { content } => assert_eq!(content, "hello 世界"), Segment::Text { content } => assert_eq!(content, "hello 世界"),
+10 -6
View File
@@ -11,12 +11,13 @@ use crate::{
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult, PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement, SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent, WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus, WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
subscription::{ subscription::{
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES, EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS, MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
@@ -104,6 +105,9 @@ pub fn generated_protocol_types() -> String {
push_decl::<SessionToolAttachment>(&cfg, &mut output); push_decl::<SessionToolAttachment>(&cfg, &mut output);
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output); push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
push_decl::<SessionSnapshotEntry>(&cfg, &mut output); push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
push_decl::<SessionConversationTurn>(&cfg, &mut output);
push_decl::<SessionHistoryLineageBoundary>(&cfg, &mut output);
push_decl::<SessionHistoryPage>(&cfg, &mut output);
push_decl::<PendingSubmissionSummary>(&cfg, &mut output); push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output); push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
push_decl::<SubmissionDisposition>(&cfg, &mut output); push_decl::<SubmissionDisposition>(&cfg, &mut output);
+70 -1
View File
@@ -655,6 +655,41 @@ pub struct WorkerSessionRequest {
pub workspace_id: String, pub workspace_id: String,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSessionHistoryRequest {
pub workspace_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerSessionHistoryUnavailableReason {
RetentionMissing,
ActivePointerMissing,
CorruptLog,
MigrationRequired,
RetentionExpired,
StorageUnavailable,
InvalidCursor,
ResourceLimit,
Unsupported,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "availability", rename_all = "snake_case")]
pub enum WorkerSessionHistoryAvailability {
Page {
page: protocol::SessionHistoryPage,
},
Unavailable {
reason: WorkerSessionHistoryUnavailableReason,
message: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetainedSessionIdentity { pub struct RetainedSessionIdentity {
pub session_id: String, pub session_id: String,
@@ -718,6 +753,17 @@ pub trait RuntimeApi {
#[query] request: WorkerSessionRequest, #[query] request: WorkerSessionRequest,
) -> Result<WorkerSessionAvailability, RuntimeApiError>; ) -> Result<WorkerSessionAvailability, RuntimeApiError>;
#[get(
"/v1/workers/{worker_id}/session/history",
status = 200,
error_status = 400
)]
async fn worker_session_history(
&self,
#[path] worker_id: String,
#[query] request: WorkerSessionHistoryRequest,
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
#[post("/v1/workers", status = 200, error_status = 400)] #[post("/v1/workers", status = 200, error_status = 400)]
async fn create_worker( async fn create_worker(
&self, &self,
@@ -1053,6 +1099,17 @@ mod tests {
Ok(WorkerSessionAvailability::LiveProtocol) Ok(WorkerSessionAvailability::LiveProtocol)
} }
async fn worker_session_history(
&self,
_worker_id: String,
_request: WorkerSessionHistoryRequest,
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError> {
Ok(WorkerSessionHistoryAvailability::Unavailable {
reason: WorkerSessionHistoryUnavailableReason::Unsupported,
message: "unsupported".to_string(),
})
}
async fn create_worker( async fn create_worker(
&self, &self,
_request: CreateWorkerRequest, _request: CreateWorkerRequest,
@@ -1174,10 +1231,22 @@ mod tests {
assert_eq!(value["message"], "snapshot exceeds limit"); assert_eq!(value["message"], "snapshot exceeds limit");
} }
#[test]
fn worker_session_history_availability_has_closed_tagged_wire_shape() {
let value = serde_json::to_value(WorkerSessionHistoryAvailability::Unavailable {
reason: WorkerSessionHistoryUnavailableReason::InvalidCursor,
message: "cursor is stale".to_string(),
})
.unwrap();
assert_eq!(value["availability"], "unavailable");
assert_eq!(value["reason"], "invalid_cursor");
assert_eq!(value["message"], "cursor is stale");
}
#[test] #[test]
fn contract_inventory_is_complete_and_unique() { fn contract_inventory_is_complete_and_unique() {
let operations = RuntimeApiMetadata::OPERATIONS; let operations = RuntimeApiMetadata::OPERATIONS;
assert_eq!(operations.len(), 16); assert_eq!(operations.len(), 17);
let mut routes = operations let mut routes = operations
.iter() .iter()
.map(|operation| (format!("{:?}", operation.method), operation.path)) .map(|operation| (format!("{:?}", operation.method), operation.path))
+82 -10
View File
@@ -389,6 +389,20 @@ pub struct WorkspaceWorkerSessionResponse {
pub observation: runtime_api::WorkerSessionAvailability, pub observation: runtime_api::WorkerSessionAvailability,
} }
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceWorkerSessionHistoryQuery {
pub cursor: Option<String>,
pub limit: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceWorkerSessionHistoryResponse {
pub subject: WorkspaceWorkerSubject,
#[serde(flatten)]
pub observation: runtime_api::WorkerSessionHistoryAvailability,
}
#[api(reqwest, axum, openapi)] #[api(reqwest, axum, openapi)]
pub trait ServerApi { pub trait ServerApi {
#[get("/health", status = 200, error_status = 400)] #[get("/health", status = 200, error_status = 400)]
@@ -1141,6 +1155,20 @@ pub trait ServerApi {
#[path] worker_id: String, #[path] worker_id: String,
) -> Result<WorkspaceWorkerSessionResponse, ServerApiError>; ) -> Result<WorkspaceWorkerSessionResponse, ServerApiError>;
#[get(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session/history",
status = 200,
error_status = 400,
openapi = false
)]
async fn worker_session_history(
&self,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
#[query] query: WorkspaceWorkerSessionHistoryQuery,
) -> Result<WorkspaceWorkerSessionHistoryResponse, ServerApiError>;
#[get( #[get(
"/api/workers", "/api/workers",
status = 200, status = 200,
@@ -5262,27 +5290,71 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalWorkdirPermissionLevel {
Read,
Write,
Command,
}
impl ExternalWorkdirPermissionLevel {
pub const fn permissions(self) -> ExternalWorkdirPermissions {
match self {
Self::Read => ExternalWorkdirPermissions::READ,
Self::Write => ExternalWorkdirPermissions::WRITE,
Self::Command => ExternalWorkdirPermissions::COMMAND,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ExternalWorkdirPermissions { pub struct ExternalWorkdirPermissions {
/// Read files and use read-only discovery operations such as Glob and Grep. /// Read files and use read-only discovery operations such as Glob and Grep.
/// Every supported External Workdir permission level enables this field.
pub read: bool, pub read: bool,
/// Create or update files with Write and Edit. Existing-file mutation also /// Create or update files with Write and Edit. WRITE includes READ so callers
/// requires `read` so callers can satisfy the read-before-write contract. /// can satisfy the read-before-write contract.
pub write: bool, pub write: bool,
/// Execute commands as the sharing CLI user on the provider host. This is /// Execute commands as the sharing CLI user on the provider host. COMMAND
/// explicit authority and does not imply either filesystem category. /// includes WRITE and READ, remains explicit, and is not sandboxed.
pub command: bool, pub command: bool,
} }
impl ExternalWorkdirPermissions {
pub const READ: Self = Self {
read: true,
write: false,
command: false,
};
pub const WRITE: Self = Self {
read: true,
write: true,
command: false,
};
pub const COMMAND: Self = Self {
read: true,
write: true,
command: true,
};
/// Returns the single hierarchical level represented by a grant permission
/// object. Partial combinations remain valid for attachment/session
/// projections, but are not valid authority for creating an External grant.
pub const fn grant_level(self) -> Option<ExternalWorkdirPermissionLevel> {
match (self.read, self.write, self.command) {
(true, false, false) => Some(ExternalWorkdirPermissionLevel::Read),
(true, true, false) => Some(ExternalWorkdirPermissionLevel::Write),
(true, true, true) => Some(ExternalWorkdirPermissionLevel::Command),
_ => None,
}
}
}
impl Default for ExternalWorkdirPermissions { impl Default for ExternalWorkdirPermissions {
fn default() -> Self { fn default() -> Self {
Self { Self::READ
read: true,
write: false,
command: false,
}
} }
} }
@@ -11462,7 +11534,7 @@ mod openapi_artifact_tests {
]; ];
// Session observation retains its existing explicit non-OpenAPI boundary independently of // Session observation retains its existing explicit non-OpenAPI boundary independently of
// the Runtime/Worker-source signed operation inventory above. // the Runtime/Worker-source signed operation inventory above.
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session"]; const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session", "worker_session_history"];
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"]; const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"];
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"]; const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
+17 -5
View File
@@ -37,23 +37,31 @@ use std::path::Path;
/// Read an existing retained file without updating its access timestamp. /// Read an existing retained file without updating its access timestamp.
/// Observation fails closed when the platform cannot provide that guarantee. /// Observation fails closed when the platform cannot provide that guarantee.
pub(crate) fn read_without_atime(path: &Path) -> io::Result<Vec<u8>> { pub(crate) fn read_without_atime(path: &Path) -> io::Result<Vec<u8>> {
read_without_atime_bounded(path, u64::MAX)
}
/// Read at most `max_bytes + 1` retained bytes without updating access time.
/// The extra byte lets callers prove that the authoritative file exceeded a
/// limit without first racing a separate metadata length observation.
pub(crate) fn read_without_atime_bounded(path: &Path, max_bytes: u64) -> io::Result<Vec<u8>> {
#[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(any(target_os = "linux", target_os = "android"))]
{ {
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::io::Read; use std::io::Read;
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new() let file = OpenOptions::new()
.read(true) .read(true)
.custom_flags(libc::O_NOATIME) .custom_flags(libc::O_NOATIME)
.open(path)?; .open(path)?;
let mut bytes = Vec::new(); let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?; file.take(max_bytes.saturating_add(1))
.read_to_end(&mut bytes)?;
Ok(bytes) Ok(bytes)
} }
#[cfg(not(any(target_os = "linux", target_os = "android")))] #[cfg(not(any(target_os = "linux", target_os = "android")))]
{ {
let _ = path; let _ = (path, max_bytes);
Err(io::Error::new( Err(io::Error::new(
io::ErrorKind::Unsupported, io::ErrorKind::Unsupported,
"read-only retained observation requires no-atime file reads", "read-only retained observation requires no-atime file reads",
@@ -88,8 +96,12 @@ pub use history::{
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use paste_artifact::PasteArtifactLimits; pub use paste_artifact::PasteArtifactLimits;
pub use public_snapshot::{ pub use public_snapshot::{
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, RetainedSessionIdentity, RetainedSessionSnapshot, DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
RetainedSnapshotReadError, project_session_snapshot, read_retained_session_snapshot, DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
DEFAULT_RETAINED_HISTORY_PAGE_TURNS, DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
MAX_RETAINED_HISTORY_PAGE_TURNS, RetainedHistoryReadError, RetainedHistoryReadLimits,
RetainedSessionIdentity, RetainedSessionSnapshot, RetainedSnapshotReadError,
project_session_snapshot, read_retained_session_history_page, read_retained_session_snapshot,
}; };
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,
File diff suppressed because it is too large Load Diff
+3
View File
@@ -33,6 +33,9 @@ pub enum StoreError {
#[error("log corrupted at line {line}: {message}")] #[error("log corrupted at line {line}: {message}")]
Corrupt { line: usize, message: String }, Corrupt { line: usize, message: String },
#[error("retained observation read limit exceeded")]
ReadLimitExceeded,
#[error("paste artifact storage is unavailable")] #[error("paste artifact storage is unavailable")]
PasteArtifactUnsupported, PasteArtifactUnsupported,
@@ -25,7 +25,7 @@ use crate::{
use protocol::{PasteArtifactRef, UploadedFileRef}; use protocol::{PasteArtifactRef, UploadedFileRef};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
@@ -50,6 +50,106 @@ struct SessionManifest {
session_id: SessionId, session_id: SessionId,
} }
const RETAINED_BACKWARD_READ_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug)]
pub(crate) struct RetainedLogRecord {
pub start_offset: u64,
pub end_offset: u64,
pub entry: LogEntry,
}
pub(crate) struct RetainedSegmentReader {
file: File,
read_end: u64,
buffer_start: u64,
buffer: Vec<u8>,
}
impl RetainedSegmentReader {
pub fn previous_record(
&mut self,
max_additional_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
let mut read_bytes = 0_u64;
loop {
if !self.buffer.is_empty() {
if self.buffer.last() != Some(&b'\n') {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor is not at a record boundary".to_string(),
});
}
let prior_newline = self.buffer[..self.buffer.len() - 1]
.iter()
.rposition(|byte| *byte == b'\n');
if let Some(newline) =
prior_newline.or((self.buffer_start == 0).then_some(usize::MAX))
{
let record_start_in_buffer = if newline == usize::MAX {
0
} else {
newline + 1
};
let record_start = self.buffer_start + record_start_in_buffer as u64;
let record_end = self.buffer_start + self.buffer.len() as u64;
let record =
self.buffer[record_start_in_buffer..self.buffer.len() - 1].to_vec();
self.buffer.truncate(record_start_in_buffer);
self.read_end = record_start;
if record.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
return Ok((
Some(RetainedLogRecord {
start_offset: record_start,
end_offset: record_end,
entry,
}),
read_bytes,
));
}
} else if self.read_end == 0 {
return Ok((None, read_bytes));
}
if self.buffer_start == 0 && !self.buffer.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment log does not end at a record boundary".to_string(),
});
}
if read_bytes >= max_additional_bytes {
return Err(StoreError::ReadLimitExceeded);
}
let end = if self.buffer.is_empty() {
self.read_end
} else {
self.buffer_start
};
let available = max_additional_bytes - read_bytes;
let chunk_len = end
.min(RETAINED_BACKWARD_READ_CHUNK_BYTES as u64)
.min(available);
if chunk_len == 0 {
return Err(StoreError::ReadLimitExceeded);
}
let start = end - chunk_len;
self.file.seek(SeekFrom::Start(start))?;
let mut chunk = vec![0_u8; chunk_len as usize];
self.file.read_exact(&mut chunk)?;
read_bytes += chunk_len;
chunk.extend_from_slice(&self.buffer);
self.buffer = chunk;
self.buffer_start = start;
}
}
}
impl WorkerSessionStore { impl WorkerSessionStore {
/// Open the Session store rooted at `<worker-aggregate>/session`. /// Open the Session store rooted at `<worker-aggregate>/session`.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> { pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
@@ -119,6 +219,19 @@ impl WorkerSessionStore {
session_id: SessionId, session_id: SessionId,
segment_id: SegmentId, segment_id: SegmentId,
) -> Result<Vec<LogEntry>, StoreError> { ) -> Result<Vec<LogEntry>, StoreError> {
self.read_all_read_only_bounded(session_id, segment_id, u64::MAX)
.map(|(entries, _)| entries)
}
/// Read one retained Segment under an actual byte bound without touching
/// access time. The file descriptor read, rather than a preceding stat, is
/// the limit authority so concurrent appends cannot evade accounting.
pub fn read_all_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
max_bytes: u64,
) -> Result<(Vec<LogEntry>, u64), StoreError> {
let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt { let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt {
line: 0, line: 0,
message: "Worker Session identity lock poisoned".to_string(), message: "Worker Session identity lock poisoned".to_string(),
@@ -131,14 +244,210 @@ impl WorkerSessionStore {
}); });
} }
let path = self.log_path(segment_id); let path = self.log_path(segment_id);
let bytes = crate::read_without_atime(&path).map_err(|error| { let bytes = crate::read_without_atime_bounded(&path, max_bytes).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound { if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id) StoreError::NotFound(segment_id)
} else { } else {
StoreError::Io(error) StoreError::Io(error)
} }
})?; })?;
parse_jsonl(&bytes) let byte_len = bytes.len() as u64;
if byte_len > max_bytes {
return Err(StoreError::ReadLimitExceeded);
}
Ok((parse_jsonl(&bytes)?, byte_len))
}
pub(crate) fn read_first_log_record_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
max_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64, u64), StoreError> {
self.validate_retained_session(session_id)?;
let file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
let file_len = file.metadata()?.len();
if file_len == 0 {
return Ok((None, 0, 0));
}
let mut reader = BufReader::new(file.take(max_bytes.saturating_add(1)));
let mut record = Vec::new();
let read_bytes = reader.read_until(b'\n', &mut record)? as u64;
if read_bytes > max_bytes || record.last() != Some(&b'\n') {
return Err(StoreError::ReadLimitExceeded);
}
record.pop();
if record.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
Ok((
Some(RetainedLogRecord {
start_offset: 0,
end_offset: read_bytes,
entry,
}),
read_bytes,
file_len,
))
}
pub(crate) fn read_next_log_record_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
start_offset: u64,
max_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
self.validate_retained_session(session_id)?;
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
let file_len = file.metadata()?.len();
if start_offset >= file_len {
return Ok((None, 0));
}
file.seek(SeekFrom::Start(start_offset))?;
let mut reader = BufReader::new(file.take(max_bytes.saturating_add(1)));
let mut record = Vec::new();
let read_bytes = reader.read_until(b'\n', &mut record)? as u64;
if read_bytes > max_bytes || record.last() != Some(&b'\n') {
return Err(StoreError::ReadLimitExceeded);
}
record.pop();
if record.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
Ok((
Some(RetainedLogRecord {
start_offset,
end_offset: start_offset + read_bytes,
entry,
}),
read_bytes,
))
}
pub(crate) fn read_log_record_range_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
start_offset: u64,
end_offset: u64,
max_bytes: u64,
) -> Result<RetainedLogRecord, StoreError> {
self.validate_retained_session(session_id)?;
let record_len =
end_offset
.checked_sub(start_offset)
.ok_or_else(|| StoreError::Corrupt {
line: 0,
message: "retained Segment record range is reversed".to_string(),
})?;
if record_len == 0 || record_len > max_bytes {
return Err(StoreError::ReadLimitExceeded);
}
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
if end_offset > file.metadata()?.len() {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment record range exceeds the committed log".to_string(),
});
}
file.seek(SeekFrom::Start(start_offset))?;
let mut record = vec![0_u8; record_len as usize];
file.read_exact(&mut record)?;
if record.last() != Some(&b'\n') || record[..record.len() - 1].contains(&b'\n') {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor does not identify one record".to_string(),
});
}
record.pop();
let entry = serde_json::from_slice(&record)?;
Ok(RetainedLogRecord {
start_offset,
end_offset,
entry,
})
}
pub(crate) fn open_retained_segment_reader(
&self,
session_id: SessionId,
segment_id: SegmentId,
before_offset: u64,
) -> Result<RetainedSegmentReader, StoreError> {
self.validate_retained_session(session_id)?;
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
let file_len = file.metadata()?.len();
if before_offset > file_len {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor is beyond the committed log".to_string(),
});
}
if before_offset > 0 {
file.seek(SeekFrom::Start(before_offset - 1))?;
let mut boundary = [0_u8; 1];
file.read_exact(&mut boundary)?;
if boundary[0] != b'\n' {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor is not at a record boundary".to_string(),
});
}
}
Ok(RetainedSegmentReader {
file,
read_end: before_offset,
buffer_start: before_offset,
buffer: Vec::new(),
})
}
fn validate_retained_session(&self, requested: SessionId) -> Result<(), StoreError> {
let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt {
line: 0,
message: "Worker Session identity lock poisoned".to_string(),
})?;
if *retained_session_id != Some(requested) {
return Err(StoreError::Corrupt {
line: 0,
message: "active Worker Session identity does not match retained manifest"
.to_string(),
});
}
Ok(())
} }
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> { pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
@@ -649,6 +958,25 @@ impl Store for WorkerSessionStore {
} }
} }
fn open_retained_file(path: &Path) -> std::io::Result<File> {
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOATIME)
.open(path)
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
let _ = path;
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"read-only retained observation requires no-atime file reads",
))
}
}
fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> { fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> {
let segments = root.join(SEGMENTS_DIR); let segments = root.join(SEGMENTS_DIR);
if !segments.exists() { if !segments.exists() {
+1 -1
View File
@@ -117,7 +117,7 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
.expect("protocol event") .expect("protocol event")
.expect("worker event") .expect("worker event")
{ {
Event::UserMessage { segments } Event::UserMessage { segments, .. }
if format!("{segments:?}").contains("read the probe") => if format!("{segments:?}").contains("read the probe") =>
{ {
saw_user_message = true; saw_user_message = true;
+92 -21
View File
@@ -7,12 +7,14 @@ use agen::tool::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use serde::Deserialize; use serde::Deserialize;
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; use workdir::{ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
use crate::error::ToolsError; use crate::error::ToolsError;
/// Maximum image body accepted for one model request. /// Maximum image body accepted for one model request.
pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
/// One binary response remains within the External Workdir transport bound.
const IMAGE_READ_CHUNK_BYTES: usize = 1024 * 1024;
const DESCRIPTION: &str = "Attach an image from the selected Workdir attachment to the next model request. \ const DESCRIPTION: &str = "Attach an image from the selected Workdir attachment to the next model request. \
The path must be logical and Workdir-relative. Supported formats: PNG, JPEG, GIF, and WebP. \ The path must be logical and Workdir-relative. Supported formats: PNG, JPEG, GIF, and WebP. \
@@ -47,42 +49,111 @@ impl Tool for ViewImageTool {
workdir::WorkdirSessionCapability::Read, workdir::WorkdirSessionCapability::Read,
)?; )?;
let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?; let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?;
let result = selected let bytes = read_image_bytes(selected.session.as_ref(), path.clone())
.session
.read(ReadRequest {
path: path.clone(),
offset: 0,
limit: usize::MAX,
// The scoped provider enforces this cap while reading, rather
// than allocating an unbounded binary body first.
max_bytes: MAX_IMAGE_BYTES + 1,
})
.await .await
.map_err(ToolsError::from)?; .map_err(ToolsError::from)?;
let mime_type = detect_image_mime(&bytes).ok_or_else(|| {
if result.truncated || result.bytes.len() > MAX_IMAGE_BYTES {
return Err(ToolError::InvalidArgument(format!(
"image exceeds the {MAX_IMAGE_BYTES}-byte limit"
)));
}
let mime_type = detect_image_mime(&result.bytes).ok_or_else(|| {
ToolError::InvalidArgument( ToolError::InvalidArgument(
"unsupported image; expected PNG, JPEG, GIF, or WebP bytes".to_string(), "unsupported image; expected PNG, JPEG, GIF, or WebP bytes".to_string(),
) )
})?; })?;
let bytes = result.bytes.len(); let byte_count = bytes.len();
Ok(ToolOutput { Ok(ToolOutput {
summary: format!("Attached image {path} ({mime_type}, {bytes} bytes)"), summary: format!("Attached image {path} ({mime_type}, {byte_count} bytes)"),
content: None, content: None,
attachments: vec![Attachment::Image(ImageAttachment::new( attachments: vec![Attachment::Image(ImageAttachment::new(
mime_type, mime_type,
Arc::<[u8]>::from(result.bytes), Arc::<[u8]>::from(bytes),
))], ))],
}) })
} }
} }
async fn read_image_bytes(
session: &dyn workdir::WorkdirSession,
path: WorkdirPath,
) -> Result<Vec<u8>, workdir::WorkdirError> {
let mut bytes = Vec::new();
let mut offset = 0_u64;
let mut expected_hash = None;
let mut expected_total = None;
loop {
let remaining = expected_total
.map(|total: u64| total.saturating_sub(offset) as usize)
.unwrap_or(IMAGE_READ_CHUNK_BYTES);
let request_bytes = remaining.min(IMAGE_READ_CHUNK_BYTES).max(1);
let result = session
.read_bytes(ReadBytesRequest {
path: path.clone(),
offset,
max_bytes: request_bytes,
expected_hash,
})
.await?;
if result.path != path || result.offset != offset {
return Err(workdir::WorkdirError::Transport(
"bounded binary read returned a mismatched path or offset".to_string(),
));
}
if result.bytes.len() > request_bytes {
return Err(workdir::WorkdirError::Transport(
"bounded binary read exceeded the requested response size".to_string(),
));
}
let end = result
.offset
.checked_add(result.bytes.len() as u64)
.ok_or_else(|| {
workdir::WorkdirError::Transport(
"bounded binary read returned an invalid byte range".to_string(),
)
})?;
if end > result.total_bytes || result.eof != (end == result.total_bytes) {
return Err(workdir::WorkdirError::Transport(
"bounded binary read returned a truncated or invalid byte range".to_string(),
));
}
if result.total_bytes > MAX_IMAGE_BYTES as u64 {
return Err(workdir::WorkdirError::InvalidArgument(format!(
"image exceeds the {MAX_IMAGE_BYTES}-byte limit"
)));
}
if expected_total.is_some_and(|total| total != result.total_bytes)
|| expected_hash.is_some_and(|hash| hash != result.content_hash)
{
return Err(workdir::WorkdirError::Conflict(
"image changed while it was being read; retry ViewImage".to_string(),
));
}
if expected_total.is_none() {
bytes
.try_reserve_exact(result.total_bytes as usize)
.map_err(|_| workdir::WorkdirError::OperationFailed)?;
expected_total = Some(result.total_bytes);
expected_hash = Some(result.content_hash);
}
if result.bytes.is_empty() && !result.eof {
return Err(workdir::WorkdirError::Transport(
"bounded binary read made no progress".to_string(),
));
}
bytes.extend_from_slice(&result.bytes);
offset = end;
if result.eof {
if bytes.len() as u64 != result.total_bytes {
return Err(workdir::WorkdirError::Transport(
"bounded binary read returned a partial image".to_string(),
));
}
return Ok(bytes);
}
}
}
pub fn view_image_tool(session: WorkdirSessionHandle) -> ToolDefinition { pub fn view_image_tool(session: WorkdirSessionHandle) -> ToolDefinition {
routed_view_image_tool(crate::routing::singleton_router(session)) routed_view_image_tool(crate::routing::singleton_router(session))
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 B

+326 -3
View File
@@ -18,7 +18,9 @@ use tools::{
Tracker, core_builtin_tools, routed_builtin_tools, routed_view_image_tool, view_image_tool, Tracker, core_builtin_tools, routed_builtin_tools, routed_view_image_tool, view_image_tool,
}; };
use workdir::{ use workdir::{
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter, LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirPath, WorkdirSessionHandle,
WorkdirSessionRouter, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
WorkdirToolScopeRule,
}; };
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope { fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
@@ -94,6 +96,33 @@ fn setup_routed() -> (
(left, right, spill, router, Registry::new(definitions)) (left, right, spill, router, Registry::new(definitions))
} }
fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
let fixture = include_bytes!("fixtures/view-image/valid.gif");
assert_eq!(
fixture.last(),
Some(&0x3b),
"GIF fixture must end in trailer"
);
let mut bytes = fixture[..fixture.len() - 1].to_vec();
bytes.extend_from_slice(&[0x21, 0xfe]);
let mut remaining = target_len
.checked_sub(bytes.len() + 2)
.expect("target must leave room for comment terminator and GIF trailer");
while remaining > 0 {
let mut block = remaining.min(256);
if remaining - block == 1 {
block -= 1;
}
assert!(block >= 2);
bytes.push((block - 1) as u8);
bytes.extend(std::iter::repeat_n(b'x', block - 1));
remaining -= block;
}
bytes.extend_from_slice(&[0, 0x3b]);
assert_eq!(bytes.len(), target_len);
bytes
}
async fn call(tool: &Arc<dyn Tool>, input: serde_json::Value) -> agen::tool::ToolOutput { async fn call(tool: &Arc<dyn Tool>, input: serde_json::Value) -> agen::tool::ToolOutput {
tool.execute(&input.to_string(), Default::default()) tool.execute(&input.to_string(), Default::default())
.await .await
@@ -399,6 +428,301 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
router.close_all().await.unwrap(); router.close_all().await.unwrap();
} }
#[derive(Debug)]
struct ExternalValidatingSession {
inner: LocalWorkdirSession,
truncate_first_binary_response: bool,
mutate_after_first_binary_response: Option<std::path::PathBuf>,
}
#[async_trait::async_trait]
impl workdir::WorkdirSession for ExternalValidatingSession {
fn workdir(&self) -> &workdir::Workdir {
self.inner.workdir()
}
fn capabilities(&self) -> workdir::WorkdirSessionCapabilities {
self.inner.capabilities()
}
async fn stat(
&self,
request: workdir::StatRequest,
) -> Result<workdir::StatResult, workdir::WorkdirError> {
self.inner.stat(request).await
}
async fn read(
&self,
request: workdir::ReadRequest,
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
let operation = workdir::http::WorkdirSessionOperation::Read(request);
let operation = workdir::external::ExternalWorkdirOperation::try_from(operation)
.map_err(workdir::WorkdirError::UnsupportedOperation)?;
let workdir::http::WorkdirSessionOperation::Read(request) = operation.into_inner() else {
unreachable!("validated Read changed operation kind")
};
self.inner.read(request).await
}
async fn read_bytes(
&self,
request: workdir::ReadBytesRequest,
) -> Result<workdir::ReadBytesResult, workdir::WorkdirError> {
let operation = workdir::http::WorkdirSessionOperation::ReadBytes(request);
let operation = workdir::external::ExternalWorkdirOperation::try_from(operation)
.map_err(workdir::WorkdirError::UnsupportedOperation)?;
let workdir::http::WorkdirSessionOperation::ReadBytes(request) = operation.into_inner()
else {
unreachable!("validated ReadBytes changed operation kind")
};
let result = self.inner.read_bytes(request).await?;
let result = workdir::external::ExternalWorkdirOperationResult::try_from(
workdir::http::WorkdirSessionOperationResult::ReadBytes(result),
)
.map_err(workdir::WorkdirError::UnsupportedOperation)?;
let workdir::http::WorkdirSessionOperationResult::ReadBytes(mut result) =
result.into_inner()
else {
unreachable!("validated ReadBytes result changed operation kind")
};
if result.offset == 0 {
if let Some(path) = &self.mutate_after_first_binary_response {
std::fs::write(path, vec![b'z'; result.total_bytes as usize]).unwrap();
}
if self.truncate_first_binary_response {
result.bytes.pop();
}
}
Ok(result)
}
async fn write(
&self,
request: workdir::WriteRequest,
) -> Result<workdir::WriteResult, workdir::WorkdirError> {
self.inner.write(request).await
}
async fn edit(
&self,
request: workdir::EditRequest,
) -> Result<workdir::EditResult, workdir::WorkdirError> {
self.inner.edit(request).await
}
async fn list(
&self,
request: workdir::ListRequest,
) -> Result<workdir::ListResult, workdir::WorkdirError> {
self.inner.list(request).await
}
async fn glob(
&self,
request: workdir::GlobRequest,
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
self.inner.glob(request).await
}
async fn grep(
&self,
request: workdir::GrepRequest,
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
self.inner.grep(request).await
}
async fn start_command(
&self,
request: workdir::CommandRequest,
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
self.inner.start_command(request).await
}
async fn command_status(
&self,
handle: workdir::CommandHandle,
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
self.inner.command_status(handle).await
}
async fn command_output(
&self,
request: workdir::CommandOutputRequest,
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
self.inner.command_output(request).await
}
async fn cancel_command(
&self,
handle: workdir::CommandHandle,
) -> Result<(), workdir::WorkdirError> {
self.inner.cancel_command(handle).await
}
async fn close(&self) -> Result<(), workdir::WorkdirError> {
self.inner.close().await
}
}
#[tokio::test]
async fn external_view_image_uses_a_protocol_bounded_request() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
truncate_first_binary_response: false,
mutate_after_first_binary_response: None,
});
let png = include_bytes!("fixtures/view-image/valid.png");
assert!(png.contains(&0));
assert!(png.iter().any(|byte| *byte >= 0x80));
std::fs::write(dir.path().join("image.png"), png).unwrap();
let (_meta, tool) = view_image_tool(session)();
let output = call(&tool, json!({ "path": "image.png" })).await;
let agen::tool::Attachment::Image(image) = &output.attachments[0];
assert_eq!(image.mime_type(), "image/png");
assert_eq!(image.data(), png);
}
#[tokio::test]
async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let image_path = dir.path().join("image.png");
let scope = scope_with_spill(dir.path(), spill.path());
std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap();
let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()),
truncate_first_binary_response: true,
mutate_after_first_binary_response: None,
});
let (_meta, tool) = view_image_tool(truncated)();
let error = call_err(&tool, json!({ "path": "image.png" }))
.await
.to_string();
assert!(
error.contains("truncated") || error.contains("partial"),
"{error}"
);
let large = valid_gif_with_len(1024 * 1024 + 32);
std::fs::write(&image_path, large).unwrap();
let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
truncate_first_binary_response: false,
mutate_after_first_binary_response: Some(image_path),
});
let (_meta, tool) = view_image_tool(changing)();
let error = call_err(&tool, json!({ "path": "image.png" }))
.await
.to_string();
assert!(error.contains("changed"), "{error}");
}
#[tokio::test]
async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() {
const LIMIT: usize = 10 * 1024 * 1024;
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let (_meta, tool) = view_image_tool(session)();
let mut image = valid_gif_with_len(LIMIT);
std::fs::write(dir.path().join("boundary.gif"), &image).unwrap();
let output = call(&tool, json!({ "path": "boundary.gif" })).await;
let agen::tool::Attachment::Image(attachment) = &output.attachments[0];
assert_eq!(attachment.mime_type(), "image/gif");
assert_eq!(attachment.data(), image);
image.push(0);
std::fs::write(dir.path().join("boundary.gif"), image).unwrap();
let error = call_err(&tool, json!({ "path": "boundary.gif" }))
.await
.to_string();
assert!(error.contains("10485760-byte limit"), "{error}");
}
#[tokio::test]
async fn view_image_attaches_valid_supported_image_fixtures() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let (_meta, tool) = view_image_tool(session)();
let fixtures: [(&str, &[u8], &str); 4] = [
(
"valid.png",
include_bytes!("fixtures/view-image/valid.png"),
"image/png",
),
(
"valid.jpg",
include_bytes!("fixtures/view-image/valid.jpg"),
"image/jpeg",
),
(
"valid.gif",
include_bytes!("fixtures/view-image/valid.gif"),
"image/gif",
),
(
"valid.webp",
include_bytes!("fixtures/view-image/valid.webp"),
"image/webp",
),
];
for (name, fixture, mime) in fixtures {
std::fs::write(dir.path().join(name), fixture).unwrap();
let output = call(&tool, json!({ "path": name })).await;
let agen::tool::Attachment::Image(image) = &output.attachments[0];
assert_eq!(image.mime_type(), mime);
assert_eq!(image.data(), fixture);
}
}
#[tokio::test]
async fn delegated_view_image_preserves_caller_relative_path_identity() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("docs")).unwrap();
let fixture = include_bytes!("fixtures/view-image/valid.png");
std::fs::write(dir.path().join("docs/image.png"), fixture).unwrap();
let source: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
Scope::writable(dir.path()).unwrap(),
dir.path().to_path_buf(),
));
let broker = WorkdirToolBroker::new(source);
let lease = broker
.scope(WorkdirToolScope {
rules: vec![WorkdirToolScopeRule {
target: WorkdirPath::new("docs").unwrap(),
permission: WorkdirToolScopePermission::Read,
recursive: true,
symlink_policy: Default::default(),
}],
cwd: WorkdirPath::new("docs").unwrap(),
command: false,
})
.await
.unwrap();
let (_meta, tool) = view_image_tool(lease.tool_session())();
let output = call(&tool, json!({ "path": "image.png" })).await;
assert!(output.summary.contains("image.png"));
assert!(!output.summary.contains("docs/image.png"));
let agen::tool::Attachment::Image(image) = &output.attachments[0];
assert_eq!(image.data(), fixture);
lease.close().await.unwrap();
broker.close().await.unwrap();
}
#[tokio::test] #[tokio::test]
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -406,7 +730,7 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
let scope = scope_with_spill(dir.path(), spill.path()); let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle = let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let png = b"\x89PNG\r\n\x1a\nprivate-image-body"; let png = include_bytes!("fixtures/view-image/valid.png");
std::fs::write(dir.path().join("image.png"), png).unwrap(); std::fs::write(dir.path().join("image.png"), png).unwrap();
let definition = view_image_tool(session); let definition = view_image_tool(session);
let (_meta, tool) = definition(); let (_meta, tool) = definition();
@@ -417,7 +741,6 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
assert_eq!(image.mime_type(), "image/png"); assert_eq!(image.mime_type(), "image/png");
assert_eq!(image.data(), png); assert_eq!(image.data(), png);
let serialized = serde_json::to_string(&output).unwrap(); let serialized = serde_json::to_string(&output).unwrap();
assert!(!serialized.contains("private-image-body"));
assert!(serialized.contains("attachments")); assert!(serialized.contains("attachments"));
let restored: agen::tool::ToolOutput = serde_json::from_str(&serialized).unwrap(); let restored: agen::tool::ToolOutput = serde_json::from_str(&serialized).unwrap();
let agen::tool::Attachment::Image(restored_image) = &restored.attachments[0]; let agen::tool::Attachment::Image(restored_image) = &restored.attachments[0];
+8 -1
View File
@@ -1148,7 +1148,7 @@ impl App {
Event::PendingSubmissionsChanged { pending } => { Event::PendingSubmissionsChanged { pending } => {
self.pending_submissions = pending; self.pending_submissions = pending;
} }
Event::UserMessage { segments } => { Event::UserMessage { segments, .. } => {
self.turn_index += 1; self.turn_index += 1;
self.blocks.push(Block::TurnHeader { self.blocks.push(Block::TurnHeader {
turn: self.turn_index, turn: self.turn_index,
@@ -1168,6 +1168,9 @@ impl App {
} }
self.assistant_streaming = false; self.assistant_streaming = false;
} }
// The TUI already renders assistant/tool streaming events. This commit
// marker exists so identity-aware clients can reconcile durable history.
Event::SessionEntryCommitted { .. } => {}
Event::SystemItem { item } => { Event::SystemItem { item } => {
self.apply_system_item(&item); self.apply_system_item(&item);
self.assistant_streaming = false; self.assistant_streaming = false;
@@ -3391,6 +3394,7 @@ mod completion_flow_tests {
]), ]),
}); });
app.handle_worker_event(Event::UserMessage { app.handle_worker_event(Event::UserMessage {
entry_id: None,
segments: vec![Segment::text("first persisted message")], segments: vec![Segment::text("first persisted message")],
}); });
@@ -3409,6 +3413,7 @@ mod completion_flow_tests {
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
app.handle_worker_event(Event::UserMessage { app.handle_worker_event(Event::UserMessage {
entry_id: None,
segments: submitted, segments: submitted,
}); });
// Simulate run-derived attachment display after the submitted user line. // Simulate run-derived attachment display after the submitted user line.
@@ -3449,6 +3454,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let submitted = submit_text(&mut app, "original submit"); let submitted = submit_text(&mut app, "original submit");
app.handle_worker_event(Event::UserMessage { app.handle_worker_event(Event::UserMessage {
entry_id: None,
segments: submitted, segments: submitted,
}); });
for c in "draft while running".chars() { for c in "draft while running".chars() {
@@ -3477,6 +3483,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let submitted = submit_text(&mut app, "normal run"); let submitted = submit_text(&mut app, "normal run");
app.handle_worker_event(Event::UserMessage { app.handle_worker_event(Event::UserMessage {
entry_id: None,
segments: submitted, segments: submitted,
}); });
app.handle_worker_event(Event::RunEnd { result }); app.handle_worker_event(Event::RunEnd { result });
+5 -1
View File
@@ -1688,6 +1688,7 @@ mod tests {
); );
connection.observe_worker_event(&Event::UserMessage { connection.observe_worker_event(&Event::UserMessage {
entry_id: None,
segments: vec![Segment::text("inspect"), Segment::UploadedFile { file }], segments: vec![Segment::text("inspect"), Segment::UploadedFile { file }],
}); });
assert!(connection.pending_attachments.is_empty()); assert!(connection.pending_attachments.is_empty());
@@ -1779,7 +1780,10 @@ mod tests {
assert!(!reconcile_attachment_submission( assert!(!reconcile_attachment_submission(
&mut pending, &mut pending,
&mut awaiting, &mut awaiting,
&Event::UserMessage { segments }, &Event::UserMessage {
entry_id: None,
segments,
},
)); ));
assert_eq!(awaiting, expected); assert_eq!(awaiting, expected);
assert!(pending.is_empty()); assert!(pending.is_empty());
+61 -5
View File
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError}; use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError};
use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities}; use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities};
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 1; pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 2;
/// Maximum serialized Backend-to-provider command frame size. /// Maximum serialized Backend-to-provider command frame size.
pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024; pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024;
/// Maximum serialized provider-to-Backend result frame size. Read bytes are /// Maximum serialized provider-to-Backend result frame size. Read bytes are
@@ -155,6 +155,12 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
&& request.limit <= 1_000_000 && request.limit <= 1_000_000
&& request.max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes && request.max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
} }
WorkdirSessionOperation::ReadBytes(request) => {
is_root_relative(&request.path)
&& request.offset <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes
&& (1..=BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes)
.contains(&request.max_bytes)
}
WorkdirSessionOperation::List(request) => { WorkdirSessionOperation::List(request) => {
is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS
} }
@@ -275,6 +281,15 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
is_root_relative(&result.path) is_root_relative(&result.path)
&& result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes && result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
} }
WorkdirSessionOperationResult::ReadBytes(result) => {
let returned_end = result.offset.checked_add(result.bytes.len() as u64);
is_root_relative(&result.path)
&& result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
&& result.total_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes
&& returned_end.is_some_and(|end| {
end <= result.total_bytes && result.eof == (end == result.total_bytes)
})
}
WorkdirSessionOperationResult::List(result) => { WorkdirSessionOperationResult::List(result) => {
result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS
&& result && result
@@ -482,8 +497,8 @@ mod tests {
use super::*; use super::*;
use crate::{ use crate::{
CommandRequest, EditRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath, CommandRequest, EditRequest, GrepResult, ListRequest, ReadBytesRequest, ReadBytesResult,
WriteRequest, ReadRequest, Workdir, WorkdirPath, WriteRequest,
}; };
fn assert_no_provider_authority(value: &Value) { fn assert_no_provider_authority(value: &Value) {
@@ -614,7 +629,7 @@ mod tests {
#[test] #[test]
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() { fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
let future = r#"{ let future = r#"{
"version": 2, "version": 3,
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1} "message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
}"#; }"#;
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err()); assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err());
@@ -726,10 +741,51 @@ mod tests {
); );
} }
#[test]
fn protocol_bounds_binary_read_requests_and_rejects_partial_results() {
let path = WorkdirPath::new("image.png").unwrap();
let bounded = WorkdirSessionOperation::ReadBytes(ReadBytesRequest {
path: path.clone(),
offset: 0,
max_bytes: BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes,
expected_hash: None,
});
assert!(ExternalWorkdirOperation::try_from(bounded).is_ok());
let oversized = WorkdirSessionOperation::ReadBytes(ReadBytesRequest {
path: path.clone(),
offset: 0,
max_bytes: BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1,
expected_hash: None,
});
assert!(ExternalWorkdirOperation::try_from(oversized).is_err());
let hash = [7; 32];
let complete = WorkdirSessionOperationResult::ReadBytes(ReadBytesResult {
path: path.clone(),
bytes: vec![1, 2, 3],
offset: 0,
total_bytes: 3,
content_hash: hash,
eof: true,
});
assert!(ExternalWorkdirOperationResult::try_from(complete).is_ok());
let partial_marked_complete = WorkdirSessionOperationResult::ReadBytes(ReadBytesResult {
path,
bytes: vec![1, 2],
offset: 0,
total_bytes: 3,
content_hash: hash,
eof: true,
});
assert!(ExternalWorkdirOperationResult::try_from(partial_marked_complete).is_err());
}
#[test] #[test]
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() { fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
let unknown = r#"{ let unknown = r#"{
"version": 1, "version": 2,
"message": { "message": {
"kind": "heartbeat", "kind": "heartbeat",
"generation": 1, "generation": 1,
+22 -3
View File
@@ -11,9 +11,9 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, WorkdirError, WorkdirId, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest,
WorkdirSessionCapabilities, WriteRequest, WriteResult, WorkdirSession, WorkdirSessionCapabilities, WriteRequest, WriteResult,
}; };
/// Opaque Runtime-owned identifier for one ephemeral Workdir session. /// Opaque Runtime-owned identifier for one ephemeral Workdir session.
@@ -66,6 +66,7 @@ pub enum WorkdirSessionOperation {
ScopeRulesOverlap(WorkdirScopeOverlapRequest), ScopeRulesOverlap(WorkdirScopeOverlapRequest),
Stat(StatRequest), Stat(StatRequest),
Read(ReadRequest), Read(ReadRequest),
ReadBytes(ReadBytesRequest),
Write(WriteRequest), Write(WriteRequest),
Edit(EditRequest), Edit(EditRequest),
List(ListRequest), List(ListRequest),
@@ -97,6 +98,7 @@ pub enum WorkdirSessionOperationResult {
ScopeRulesOverlap { overlaps: bool }, ScopeRulesOverlap { overlaps: bool },
Stat(StatResult), Stat(StatResult),
Read(ReadResult), Read(ReadResult),
ReadBytes(ReadBytesResult),
Write(WriteResult), Write(WriteResult),
Edit(EditResult), Edit(EditResult),
List(ListResult), List(ListResult),
@@ -135,6 +137,10 @@ pub async fn dispatch_workdir_session_operation(
.read(request) .read(request)
.await .await
.map(WorkdirSessionOperationResult::Read), .map(WorkdirSessionOperationResult::Read),
WorkdirSessionOperation::ReadBytes(request) => session
.read_bytes(request)
.await
.map(WorkdirSessionOperationResult::ReadBytes),
WorkdirSessionOperation::Write(request) => session WorkdirSessionOperation::Write(request) => session
.write(request) .write(request)
.await .await
@@ -572,6 +578,19 @@ mod client {
} }
} }
async fn read_bytes(
&self,
request: ReadBytesRequest,
) -> Result<ReadBytesResult, WorkdirError> {
match self
.operate(WorkdirSessionOperation::ReadBytes(request))
.await?
{
WorkdirSessionOperationResult::ReadBytes(result) => Ok(result),
_ => Err(Self::mismatch("read_bytes")),
}
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
match self match self
.operate(WorkdirSessionOperation::Write(request)) .operate(WorkdirSessionOperation::Write(request))
+10 -1
View File
@@ -27,7 +27,8 @@ use tokio::sync::broadcast;
pub use fs_operation::{ pub use fs_operation::{
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest,
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest,
StatResult, WriteRequest, WriteResult,
}; };
pub use http::dispatch_workdir_session_operation; pub use http::dispatch_workdir_session_operation;
pub use local::{ pub use local::{
@@ -220,6 +221,14 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>; async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>; async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
async fn read_bytes(
&self,
_request: ReadBytesRequest,
) -> Result<ReadBytesResult, WorkdirError> {
Err(WorkdirError::UnsupportedOperation(
"bounded binary reads are not supported by this provider".to_string(),
))
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>; async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError>; async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError>;
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError>; async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError>;
+19 -4
View File
@@ -30,10 +30,10 @@ use crate::{
BoundedReadLimits, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, BoundedReadLimits, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest,
CommandRequest, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, CommandRequest, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, WorkdirError, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest,
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
WriteResult, WorkdirToolScopePermission, WriteRequest, WriteResult,
}; };
#[cfg(test)] #[cfg(test)]
use crate::{EntryKind, WriteOutcome}; use crate::{EntryKind, WriteOutcome};
@@ -1261,6 +1261,21 @@ impl WorkdirSession for LocalWorkdirSession {
.map_err(|error| sanitize_error(error, &logical)) .map_err(|error| sanitize_error(error, &logical))
} }
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
self.validate_operation_path(&request.path)?;
let access = self.scope_access();
match self.inner.read_limits {
Some(limits) => {
fs_operation::run_read_bytes_bounded(&self.inner.root, request, &access, limits)
}
None => fs_operation::run_read_bytes(&self.inner.root, request, &access),
}
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Write)?; self.ensure_capability(WorkdirSessionCapability::Write)?;
let logical = request.path.clone(); let logical = request.path.clone();
+9 -3
View File
@@ -16,9 +16,10 @@ use tokio::sync::broadcast;
use crate::{ use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, CommandSnapshot, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
GrepResult, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, GrepResult, ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest,
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirScopeAuthorizationRequest,
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle,
WriteRequest, WriteResult,
}; };
/// Stable Worker-local routing key for one attached Workdir. /// Stable Worker-local routing key for one attached Workdir.
@@ -257,6 +258,11 @@ impl WorkdirSession for RoutedWorkdirSession {
self.state.session.read(request).await self.state.session.read(request).await
} }
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.read_bytes(request).await
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let _active = self.state.enter()?; let _active = self.state.enter()?;
self.state.session.write(request).await self.state.session.write(request).await
+33 -1
View File
@@ -6,7 +6,8 @@ use std::sync::{Arc, Mutex, Weak};
use async_trait::async_trait; use async_trait::async_trait;
use fs_operation::{ use fs_operation::{
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest,
StatResult, WriteRequest, WriteResult,
}; };
use manifest::SymlinkPolicy; use manifest::SymlinkPolicy;
use schemars::JsonSchema; use schemars::JsonSchema;
@@ -1011,6 +1012,21 @@ impl WorkdirSession for ScopedWorkdirSession {
self.source.read(request).await self.source.read(request).await
} }
async fn read_bytes(
&self,
mut request: ReadBytesRequest,
) -> Result<ReadBytesResult, WorkdirError> {
let caller_path = request.path.clone();
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path;
let mut result = self.source.read_bytes(request).await?;
result.path = caller_path;
Ok(result)
}
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await; let _scope_guard = self.scope_lock.lock().await;
let path = self let path = self
@@ -1276,6 +1292,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
self.inner.read(request).await self.inner.read(request).await
} }
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
self.inner.read_bytes(request).await
}
async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into())) Err(WorkdirError::Denied("read-only workdir session".into()))
} }
@@ -1656,6 +1676,15 @@ mod tests {
} }
} }
fn read_bytes(path: &str) -> ReadBytesRequest {
ReadBytesRequest {
path: fs_path(path),
offset: 0,
max_bytes: 1024,
expected_hash: None,
}
}
fn write(path: &str, content: &str) -> WriteRequest { fn write(path: &str, content: &str) -> WriteRequest {
WriteRequest { WriteRequest {
path: fs_path(path), path: fs_path(path),
@@ -1887,6 +1916,9 @@ mod tests {
child.read(read("readme.md")).await.unwrap().bytes, child.read(read("readme.md")).await.unwrap().bytes,
b"visible" b"visible"
); );
let binary = child.read_bytes(read_bytes("readme.md")).await.unwrap();
assert_eq!(binary.path, fs_path("readme.md"));
assert_eq!(binary.bytes, b"visible");
assert!(matches!( assert!(matches!(
child.write(write("new.md", "no")).await, child.write(write("new.md", "no")).await,
Err(WorkdirError::Denied(_)) Err(WorkdirError::Denied(_))
+24
View File
@@ -477,6 +477,13 @@ pub struct WorkerSessionObservationRequest {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerSessionHistoryRequest {
pub worker_ref: WorkerRef,
pub cursor: Option<String>,
pub limit: Option<usize>,
}
pub trait WorkerExecutionBackend: Send + Sync + 'static { pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn backend_id(&self) -> &str; fn backend_id(&self) -> &str;
@@ -497,6 +504,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
} }
} }
fn worker_session_history(
&self,
_request: WorkerSessionHistoryRequest,
) -> runtime_api::WorkerSessionHistoryAvailability {
runtime_api::WorkerSessionHistoryAvailability::Unavailable {
reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported,
message: "session history paging is not supported by this Runtime".to_string(),
}
}
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,
@@ -726,6 +743,13 @@ impl WorkerExecutionBackendRef {
self.backend.worker_session(request) self.backend.worker_session(request)
} }
pub(crate) fn worker_session_history(
&self,
request: WorkerSessionHistoryRequest,
) -> runtime_api::WorkerSessionHistoryAvailability {
self.backend.worker_session_history(request)
}
pub(crate) fn spawn_worker( pub(crate) fn spawn_worker(
&self, &self,
request: WorkerExecutionSpawnRequest, request: WorkerExecutionSpawnRequest,
@@ -260,6 +260,35 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
.map_err(api_error) .map_err(api_error)
} }
async fn worker_session_history(
&self,
worker_id: String,
request: runtime_api::WorkerSessionHistoryRequest,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, runtime_api::RuntimeApiError> {
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
.ok_or_else(|| {
runtime_api::RuntimeApiError::new(
StatusCode::FORBIDDEN.as_u16(),
"runtime_worker_session_scope_required",
"Worker Session history requires a Workspace-scoped capability",
)
})?;
if scope.workspace_id != request.workspace_id {
return Err(runtime_api::RuntimeApiError::new(
StatusCode::FORBIDDEN.as_u16(),
"runtime_worker_session_workspace_scope_mismatch",
"Worker Session Workspace scope does not match the authenticated capability",
));
}
self.state
.runtime
.worker_session_history_scoped(&scope, &worker_ref, request.cursor, request.limit)
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)
}
async fn create_worker( async fn create_worker(
&self, &self,
value: runtime_api::CreateWorkerRequest, value: runtime_api::CreateWorkerRequest,
+34 -1
View File
@@ -16,7 +16,8 @@ use crate::execution::{
WorkerExecutionBackend, WorkerExecutionBackendRef, WorkerExecutionHandle, WorkerExecutionBackend, WorkerExecutionBackendRef, WorkerExecutionHandle,
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionSpawnRequest, WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionSpawnRequest,
WorkerExecutionSpawnResult, WorkerExecutionStopRequest, WorkerLifecycleOperationId, WorkerExecutionSpawnResult, WorkerExecutionStopRequest, WorkerLifecycleOperationId,
WorkerSessionObservationRequest, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult, WorkerSessionHistoryRequest, WorkerSessionObservationRequest, WorkspaceConfigFetchRequest,
WorkspaceConfigFetchResult,
}; };
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
use crate::fs_store::{ use crate::fs_store::{
@@ -1613,6 +1614,38 @@ impl Runtime {
}) })
} }
/// Read one bounded page from the adopted durable Session lineage without
/// restoring the Worker or modifying model context.
pub fn worker_session_history_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
cursor: Option<String>,
limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, RuntimeError> {
let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?;
let _operation_guard = operation_lock
.lock()
.map_err(|_| RuntimeError::StatePoisoned)?;
self.ensure_worker_in_workspace(scope, worker_ref)?;
let backend = {
let state = self.lock()?;
state.worker(worker_ref)?;
state.execution_backend.clone()
};
Ok(match backend {
Some(backend) => backend.worker_session_history(WorkerSessionHistoryRequest {
worker_ref: worker_ref.clone(),
cursor,
limit,
}),
None => runtime_api::WorkerSessionHistoryAvailability::Unavailable {
reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported,
message: "session history paging is not supported by this Runtime".to_string(),
},
})
}
/// 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,
@@ -172,6 +172,15 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
Err(session_store::RetainedSnapshotReadError::RetentionMissing) Err(session_store::RetainedSnapshotReadError::RetentionMissing)
} }
fn retained_session_history_page(
&self,
_worker_ref: &WorkerRef,
_cursor: Option<&str>,
_limit: Option<usize>,
) -> Result<protocol::SessionHistoryPage, session_store::RetainedHistoryReadError> {
Err(session_store::RetainedHistoryReadError::RetentionMissing)
}
async fn spawn_controller( async fn spawn_controller(
&self, &self,
request: WorkerExecutionSpawnRequest, request: WorkerExecutionSpawnRequest,
@@ -1027,6 +1036,25 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
) )
} }
fn retained_session_history_page(
&self,
worker_ref: &WorkerRef,
cursor: Option<&str>,
limit: Option<usize>,
) -> Result<protocol::SessionHistoryPage, session_store::RetainedHistoryReadError> {
let aggregate_dir = self
.worker_aggregate_dir(worker_ref)
.map_err(|_| session_store::RetainedHistoryReadError::RetentionMissing)?;
let worker_name = Self::runtime_worker_name_for_ref(worker_ref);
session_store::read_retained_session_history_page(
&aggregate_dir,
&worker_name,
cursor,
limit,
session_store::RetainedHistoryReadLimits::default(),
)
}
fn observe_workspace_prompt_projection( fn observe_workspace_prompt_projection(
&self, &self,
projection: worker::WorkspacePromptProjection, projection: worker::WorkspacePromptProjection,
@@ -2216,6 +2244,48 @@ where
} }
} }
fn worker_session_history(
&self,
request: crate::execution::WorkerSessionHistoryRequest,
) -> runtime_api::WorkerSessionHistoryAvailability {
match self.factory.retained_session_history_page(
&request.worker_ref,
request.cursor.as_deref(),
request.limit,
) {
Ok(page) => runtime_api::WorkerSessionHistoryAvailability::Page { page },
Err(error) => {
let reason = match error {
session_store::RetainedHistoryReadError::RetentionMissing => {
runtime_api::WorkerSessionHistoryUnavailableReason::RetentionMissing
}
session_store::RetainedHistoryReadError::ActivePointerMissing => {
runtime_api::WorkerSessionHistoryUnavailableReason::ActivePointerMissing
}
session_store::RetainedHistoryReadError::MigrationRequired => {
runtime_api::WorkerSessionHistoryUnavailableReason::MigrationRequired
}
session_store::RetainedHistoryReadError::CorruptLog => {
runtime_api::WorkerSessionHistoryUnavailableReason::CorruptLog
}
session_store::RetainedHistoryReadError::StorageUnavailable => {
runtime_api::WorkerSessionHistoryUnavailableReason::StorageUnavailable
}
session_store::RetainedHistoryReadError::InvalidCursor => {
runtime_api::WorkerSessionHistoryUnavailableReason::InvalidCursor
}
session_store::RetainedHistoryReadError::ResourceLimit => {
runtime_api::WorkerSessionHistoryUnavailableReason::ResourceLimit
}
};
runtime_api::WorkerSessionHistoryAvailability::Unavailable {
reason,
message: error.to_string(),
}
}
}
}
fn observe_workspace_prompt_projection( fn observe_workspace_prompt_projection(
&self, &self,
projection: worker::WorkspacePromptProjection, projection: worker::WorkspacePromptProjection,
@@ -18,8 +18,8 @@ use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
use workdir::{ use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
}; };
@@ -357,6 +357,13 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
} }
} }
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::ReadBytes(request))? {
WorkdirSessionOperationResult::ReadBytes(result) => Ok(result),
_ => Err(Self::mismatch("read_bytes")),
}
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Write(request))? { match self.operate(WorkdirSessionOperation::Write(request))? {
WorkdirSessionOperationResult::Write(result) => Ok(result), WorkdirSessionOperationResult::Write(result) => Ok(result),
+6 -3
View File
@@ -619,8 +619,8 @@ mod tests {
let (committed_tx, committed_rx) = mpsc::channel(); let (committed_tx, committed_rx) = mpsc::channel();
let commit_thread = thread::spawn(move || { let commit_thread = thread::spawn(move || {
// This mirrors Worker::append_entry ordering: clear in-flight first, // This mirrors Worker::append_entry ordering: clear in-flight first,
// then publish the finalized AssistantItem. AssistantItem entries // then publish the finalized AssistantItem. Identity-aware clients
// are mirror-only and are not delivered as live entry events. // reconcile this durable commit with the preceding streamed block.
in_flight_for_commit.clear_for_committed_item_then(&assistant_item, || { in_flight_for_commit.clear_for_committed_item_then(&assistant_item, || {
sink_for_commit.publish(assistant_entry); sink_for_commit.publish(assistant_entry);
}); });
@@ -639,7 +639,10 @@ mod tests {
in_flight_snapshot.blocks.as_slice(), in_flight_snapshot.blocks.as_slice(),
[InFlightBlock::Text { text, finished: true }] if text == "done" [InFlightBlock::Text { text, finished: true }] if text == "done"
)); ));
assert!(entry_rx.try_recv().is_err()); assert!(matches!(
entry_rx.try_recv(),
Ok(LogEntry::AnnotatedAssistantItem { .. })
));
let post_commit_guard = in_flight.snapshot_guard(); let post_commit_guard = in_flight.snapshot_guard();
assert!(snapshot_from_guard(&post_commit_guard).is_empty()); assert!(snapshot_from_guard(&post_commit_guard).is_empty());
} }
+38 -2
View File
@@ -34,7 +34,41 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
session_store::public_snapshot::project_current_session_snapshot(&[entry]); session_store::public_snapshot::project_current_session_snapshot(&[entry]);
Some(Event::SegmentRotated { session }) Some(Event::SegmentRotated { session })
} }
LogEntry::AnnotatedUserInput { segments, .. } => Some(Event::UserMessage { segments }), LogEntry::AnnotatedUserInput {
ts,
segments,
history,
extensions,
} => {
let projected = session_store::public_snapshot::project_current_session_snapshot(&[
LogEntry::AnnotatedUserInput {
ts,
segments: segments.clone(),
history,
extensions,
},
]);
let entry_id = projected
.entries
.iter()
.find(|entry| {
matches!(
entry.data,
protocol::SessionSnapshotEntryData::UserInput { .. }
)
})
.map(|entry| entry.entry_id.clone());
Some(Event::UserMessage { entry_id, segments })
}
entry
@ (LogEntry::AnnotatedAssistantItem { .. } | LogEntry::AnnotatedToolResult { .. }) => {
let mut projected =
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
projected
.entries
.pop()
.map(|entry| Event::SessionEntryCommitted { entry })
}
LogEntry::AnnotatedSystemItem { entry, .. } => { LogEntry::AnnotatedSystemItem { entry, .. } => {
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize"); let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value }) Some(Event::SystemItem { item: value })
@@ -94,7 +128,9 @@ mod tests {
.expect("UserInput must be live-relevant"); .expect("UserInput must be live-relevant");
match event { match event {
Event::UserMessage { segments: echoed } => assert_eq!(echoed, segments), Event::UserMessage {
segments: echoed, ..
} => assert_eq!(echoed, segments),
other => panic!("expected UserMessage, got {other:?}"), other => panic!("expected UserMessage, got {other:?}"),
} }
} }
+6 -7
View File
@@ -92,14 +92,11 @@ impl SegmentLogSink {
/// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire. /// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire.
/// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`. /// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`.
/// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`. /// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`.
/// - assistant/tool commits → `Event::SessionEntryCommitted`.
/// - `LogEntry::Invoke` → `Event::InvokeStart`. /// - `LogEntry::Invoke` → `Event::InvokeStart`.
/// Everything else (AssistantItem, ToolResult, TurnEnd, /// Operational checkpoints remain mirror-only. Assistant/tool streaming
/// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension, /// events are still the in-flight authority; the committed-entry event adds
/// ConfigChanged) is reflected in the mirror so reconnect snapshots stay accurate, /// the durable stable identity needed to reconcile retained history.
/// but is not sent live — the streaming events (TextDelta /
/// ToolCallStart / ToolResult / TurnEnd / etc.) already provide
/// that data, and re-broadcasting it as a typed entry would just
/// double-render every block on the client side.
pub fn publish(&self, entry: LogEntry) { pub fn publish(&self, entry: LogEntry) {
let mut mirror = self let mut mirror = self
.inner .inner
@@ -122,6 +119,8 @@ impl SegmentLogSink {
entry, entry,
LogEntry::AnnotatedSegmentStart { .. } LogEntry::AnnotatedSegmentStart { .. }
| LogEntry::AnnotatedUserInput { .. } | LogEntry::AnnotatedUserInput { .. }
| LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::AnnotatedSystemItem { .. } | LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. } | LogEntry::Invoke { .. }
) )
+3
View File
@@ -50,7 +50,10 @@ zeroize.workspace = true
webauthn-rs = { workspace = true } webauthn-rs = { workspace = true }
[dev-dependencies] [dev-dependencies]
agen.workspace = true
tempfile.workspace = true tempfile.workspace = true
tools.workspace = true
yoi = { path = "../yoi" }
tower = { workspace = true, features = ["util"] } tower = { workspace = true, features = ["util"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
+72
View File
@@ -1188,6 +1188,19 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
}) })
} }
fn worker_session_history(
&self,
_workspace_id: &str,
_worker_ref: &EmbeddedWorkerRef,
_cursor: Option<String>,
_limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, String> {
Ok(runtime_api::WorkerSessionHistoryAvailability::Unavailable {
reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported,
message: "session history paging is not supported by this Runtime".to_string(),
})
}
fn observation_source( fn observation_source(
&self, &self,
_worker_id: &str, _worker_id: &str,
@@ -2017,6 +2030,31 @@ impl RuntimeRegistry {
}) })
} }
pub fn worker_session_history(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
cursor: Option<String>,
limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
validate_backend_identifier("worker_id", &worker.worker_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_history(workspace_id, &worker_ref, cursor, limit)
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: worker.runtime_id.clone(),
code: "worker_session_history_failed".to_string(),
message,
})
}
pub fn observation_source( pub fn observation_source(
&self, &self,
worker: &RuntimeWorkerRef, worker: &RuntimeWorkerRef,
@@ -2909,6 +2947,19 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
fn worker_session_history(
&self,
workspace_id: &str,
worker_ref: &EmbeddedWorkerRef,
cursor: Option<String>,
limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, String> {
let scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend");
self.runtime
.worker_session_history_scoped(&scope, worker_ref, cursor, limit)
.map_err(|error| error.to_string())
}
fn observation_source( fn observation_source(
&self, &self,
worker_id: &str, worker_id: &str,
@@ -4924,6 +4975,27 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.map_err(|diagnostic| diagnostic.message) .map_err(|diagnostic| diagnostic.message)
} }
fn worker_session_history(
&self,
workspace_id: &str,
worker_ref: &EmbeddedWorkerRef,
cursor: Option<String>,
limit: Option<usize>,
) -> Result<runtime_api::WorkerSessionHistoryAvailability, String> {
let worker_id = worker_ref.worker_id.to_string();
let request = runtime_api::WorkerSessionHistoryRequest {
workspace_id: workspace_id.to_string(),
cursor,
limit,
};
self.run_runtime_api(
self.request_timeout,
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
move |client| async move { client.worker_session_history(worker_id, request).await },
)
.map_err(|diagnostic| diagnostic.message)
}
fn observation_source( fn observation_source(
&self, &self,
worker_id: &str, worker_id: &str,
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
pub mod workdir_share;
+32 -43
View File
@@ -4,10 +4,11 @@ mod objective_cli;
mod plugin_cli; mod plugin_cli;
mod session_cli; mod session_cli;
mod ticket_cli; mod ticket_cli;
mod workdir_share;
mod worker_cleanup_cli; mod worker_cleanup_cli;
mod workspace_bootstrap; mod workspace_bootstrap;
use yoi::workdir_share;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
@@ -623,7 +624,7 @@ fn parse_workdir_args(
let mut ttl = None; let mut ttl = None;
let mut read_only = false; let mut read_only = false;
let mut read_write = false; let mut read_write = false;
let mut explicit_permissions: Option<server_api::ExternalWorkdirPermissions> = None; let mut explicit_permission_level = None;
let mut non_interactive = false; let mut non_interactive = false;
let mut index = 0; let mut index = 0;
while index < args.len() { while index < args.len() {
@@ -668,29 +669,22 @@ fn parse_workdir_args(
index += 2; index += 2;
} }
"--permission" => { "--permission" => {
let category = required_option_value(args, index, "--permission")?; let level = required_option_value(args, index, "--permission")?;
let permissions = if explicit_permission_level.is_some() {
explicit_permissions.get_or_insert(server_api::ExternalWorkdirPermissions { return Err(ParseError(
read: false, "--permission selects one level and may only be provided once".to_string(),
write: false, ));
command: false, }
}); explicit_permission_level = Some(match level.to_ascii_lowercase().as_str() {
let enabled = match category.to_ascii_lowercase().as_str() { "read" => server_api::ExternalWorkdirPermissionLevel::Read,
"read" => &mut permissions.read, "write" => server_api::ExternalWorkdirPermissionLevel::Write,
"write" => &mut permissions.write, "command" => server_api::ExternalWorkdirPermissionLevel::Command,
"command" => &mut permissions.command,
_ => { _ => {
return Err(ParseError(format!( return Err(ParseError(format!(
"unknown --permission category `{category}`; expected read, write, or command" "unknown --permission level `{level}`; expected read, write, or command"
))); )));
} }
}; });
if *enabled {
return Err(ParseError(format!(
"--permission {category} may only be provided once"
)));
}
*enabled = true;
index += 2; index += 2;
} }
"--read-only" => { "--read-only" => {
@@ -742,21 +736,18 @@ fn parse_workdir_args(
"--read-only conflicts with --read-write".to_string(), "--read-only conflicts with --read-write".to_string(),
)); ));
} }
if explicit_permissions.is_some() && (read_only || read_write) { if explicit_permission_level.is_some() && (read_only || read_write) {
return Err(ParseError( return Err(ParseError(
"--permission conflicts with legacy --read-only/--read-write".to_string(), "--permission conflicts with legacy --read-only/--read-write".to_string(),
)); ));
} }
let permissions = explicit_permissions.unwrap_or(server_api::ExternalWorkdirPermissions { let permissions = explicit_permission_level
read: true, .map(server_api::ExternalWorkdirPermissionLevel::permissions)
write: read_write, .unwrap_or(if read_write {
command: false, server_api::ExternalWorkdirPermissions::WRITE
}); } else {
if permissions.write && !permissions.read { server_api::ExternalWorkdirPermissions::READ
return Err(ParseError( });
"--permission write requires --permission read for read-before-write".to_string(),
));
}
if non_interactive && workspace_id.is_none() { if non_interactive && workspace_id.is_none() {
return Err(ParseError( return Err(ParseError(
"--non-interactive requires --workspace-id".to_string(), "--non-interactive requires --workspace-id".to_string(),
@@ -1900,7 +1891,7 @@ Usage:
yoi --backend <URL> [--workspace-id <ID>] panel yoi --backend <URL> [--workspace-id <ID>] panel
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>] yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
yoi [--backend <URL>] login [--no-wait] yoi [--backend <URL>] login [--no-wait]
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>]... [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive] yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>] [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
yoi <HOST_COMMAND> [OPTIONS] yoi <HOST_COMMAND> [OPTIONS]
Target selection: Target selection:
@@ -1946,7 +1937,7 @@ Options:
fn print_workdir_help() { fn print_workdir_help() {
println!( println!(
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, READ/WRITE/COMMAND categories, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE enables Write/Edit and requires READ for read-before-write. COMMAND is independent, disabled by default, and runs with the sharing CLI user's host authority; it is not sandboxed.\nRepeat --permission to choose explicit categories in automation. Legacy --read-only and --read-write remain file-only aliases and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --permission <CATEGORY> Enable read, write, or command; repeat per category\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for read + write (commands remain disabled)\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n" "yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, one READ/WRITE/COMMAND permission level, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE includes READ and adds Write/Edit. COMMAND includes WRITE and READ, adds command start/status/output/cancel, and runs with the sharing CLI user's host authority; it is not sandboxed.\nUse --permission once to choose the level in automation. Legacy --read-only and --read-write retain the READ and WRITE meanings and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --permission <LEVEL> Grant read, write (includes read), or command (includes write + read)\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for --permission write\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n"
); );
} }
@@ -2038,7 +2029,7 @@ mod tests {
} }
#[test] #[test]
fn workdir_share_parses_independent_command_permission() { fn workdir_share_parses_command_as_the_full_hierarchical_level() {
let args = vec![ let args = vec![
"share".to_string(), "share".to_string(),
".".to_string(), ".".to_string(),
@@ -2056,11 +2047,7 @@ mod tests {
}; };
assert_eq!( assert_eq!(
options.permissions, options.permissions,
server_api::ExternalWorkdirPermissions { server_api::ExternalWorkdirPermissions::COMMAND
read: false,
write: false,
command: true,
}
); );
} }
@@ -2131,17 +2118,19 @@ mod tests {
.contains("conflicts") .contains("conflicts")
); );
let write_without_read = vec![ let multiple_levels = vec![
"share".to_string(), "share".to_string(),
".".to_string(), ".".to_string(),
"--permission".to_string(), "--permission".to_string(),
"write".to_string(), "write".to_string(),
"--permission".to_string(),
"command".to_string(),
]; ];
assert!( assert!(
parse_workdir_args(&write_without_read, &target) parse_workdir_args(&multiple_levels, &target)
.unwrap_err() .unwrap_err()
.0 .0
.contains("requires --permission read") .contains("selects one level")
); );
let missing_workspace = TargetSelection { let missing_workspace = TargetSelection {
+162 -76
View File
@@ -25,7 +25,7 @@ use workdir::{
}; };
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkdirShareOptions { pub struct WorkdirShareOptions {
pub path: PathBuf, pub path: PathBuf,
pub workspace_id: Option<String>, pub workspace_id: Option<String>,
pub backend_url: String, pub backend_url: String,
@@ -37,6 +37,39 @@ pub(crate) struct WorkdirShareOptions {
const PROVIDER_OPERATION_TIMEOUT: Duration = Duration::from_secs(30); const PROVIDER_OPERATION_TIMEOUT: Duration = Duration::from_secs(30);
fn validate_provider_registration(message: Message, generation: u64) -> Result<(), String> {
let registered = match message {
Message::Text(text) => text,
Message::Close(Some(frame)) => {
let reason = frame.reason.trim();
return Err(if reason.is_empty() {
"Backend rejected the External Workdir provider registration".to_string()
} else {
format!("Backend rejected the External Workdir provider registration: {reason}")
});
}
Message::Close(None) => {
return Err("Backend rejected the External Workdir provider registration".to_string());
}
_ => {
return Err(
"Backend returned an invalid External Workdir registration frame".to_string(),
);
}
};
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&registered)
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
if matches!(
frame.message,
ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. }
if registered_generation == generation
) {
Ok(())
} else {
Err("Backend rejected the External Workdir provider registration".to_string())
}
}
fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation) -> Duration { fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation) -> Duration {
if matches!( if matches!(
operation, operation,
@@ -49,7 +82,7 @@ fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderEnd { pub enum ProviderEnd {
Disconnected, Disconnected,
Revoked, Revoked,
Interrupted, Interrupted,
@@ -140,47 +173,42 @@ fn authenticated_server_api_client(
Ok(ServerApiClient::with_client(base_url, http_client)) Ok(ServerApiClient::with_client(base_url, http_client))
} }
fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> String { fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> &'static str {
let mut enabled = Vec::new(); match permissions.grant_level() {
if permissions.read { Some(server_api::ExternalWorkdirPermissionLevel::Read) => "READ",
enabled.push("READ"); Some(server_api::ExternalWorkdirPermissionLevel::Write) => "WRITE (includes READ)",
Some(server_api::ExternalWorkdirPermissionLevel::Command) => {
"COMMAND (includes WRITE + READ)"
}
None => "INVALID",
} }
if permissions.write {
enabled.push("WRITE");
}
if permissions.command {
enabled.push("COMMAND");
}
enabled.join(", ")
} }
fn validate_permissions(permissions: server_api::ExternalWorkdirPermissions) -> Result<(), String> { fn validate_permissions(permissions: server_api::ExternalWorkdirPermissions) -> Result<(), String> {
if permissions.write && !permissions.read { if permissions.grant_level().is_none() {
return Err( return Err(
"WRITE requires READ so existing files can satisfy read-before-write; select both categories" "select exactly one permission level: READ, WRITE (includes READ), or COMMAND (includes WRITE + READ)"
.to_string(), .to_string(),
); );
} }
if !permissions.read && !permissions.command {
return Err("select at least one of READ or COMMAND".to_string());
}
Ok(()) Ok(())
} }
fn workdir_capabilities( fn workdir_capabilities(
permissions: server_api::ExternalWorkdirPermissions, permissions: server_api::ExternalWorkdirPermissions,
) -> workdir::WorkdirSessionCapabilities { ) -> workdir::WorkdirSessionCapabilities {
let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY; match permissions.grant_level() {
if permissions.read { Some(server_api::ExternalWorkdirPermissionLevel::Read) => {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY); workdir::WorkdirSessionCapabilities::READ_ONLY
}
Some(server_api::ExternalWorkdirPermissionLevel::Write) => {
workdir::WorkdirSessionCapabilities::READ_WRITE
}
Some(server_api::ExternalWorkdirPermissionLevel::Command) => {
workdir::WorkdirSessionCapabilities::ALL
}
None => workdir::WorkdirSessionCapabilities::EMPTY,
} }
if permissions.write {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY);
}
if permissions.command {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY);
}
capabilities
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -193,7 +221,7 @@ struct ResolvedWorkdirShareOptions {
permissions: server_api::ExternalWorkdirPermissions, permissions: server_api::ExternalWorkdirPermissions,
} }
pub(crate) fn parse_ttl(value: &str) -> Result<Duration, String> { pub fn parse_ttl(value: &str) -> Result<Duration, String> {
let (number, multiplier) = if let Some(value) = value.strip_suffix('h') { let (number, multiplier) = if let Some(value) = value.strip_suffix('h') {
(value, 60 * 60) (value, 60 * 60)
} else if let Some(value) = value.strip_suffix('m') { } else if let Some(value) = value.strip_suffix('m') {
@@ -302,14 +330,14 @@ fn configure_interactively(
writeln!( writeln!(
output, output,
"Permissions are independent categories: READ (Read/Glob/Grep), WRITE (Write/Edit), and COMMAND (host command lifecycle). WRITE requires READ for read-before-write. COMMAND runs with this CLI user's host authority and is not sandboxed." "Permission levels: READ (Read/Glob/Grep), WRITE (includes READ + Write/Edit), or COMMAND (includes WRITE + READ + host command lifecycle). COMMAND runs with this CLI user's host authority and is not sandboxed."
) )
.map_err(|error| format!("failed to write prompt: {error}"))?; .map_err(|error| format!("failed to write prompt: {error}"))?;
let Some(permission_input) = read_prompt_line( let Some(permission_input) = read_prompt_line(
input, input,
output, output,
&format!( &format!(
"Permissions [comma-separated READ/WRITE/COMMAND; current {}; q to cancel]: ", "Permission level [READ/WRITE/COMMAND; current {}; q to cancel]: ",
permissions_label(options.permissions) permissions_label(options.permissions)
), ),
)? )?
@@ -320,28 +348,20 @@ fn configure_interactively(
return Ok(None); return Ok(None);
} }
if !permission_input.is_empty() { if !permission_input.is_empty() {
let mut permissions = server_api::ExternalWorkdirPermissions { options.permissions = match permission_input.to_ascii_lowercase().as_str() {
read: false, "r" | "read" | "read-only" | "read_only" => {
write: false, server_api::ExternalWorkdirPermissions::READ
command: false,
};
for category in permission_input
.split([',', ' '])
.filter(|category| !category.is_empty())
{
match category.to_ascii_lowercase().as_str() {
"r" | "read" | "read-only" | "read_only" => permissions.read = true,
"w" | "write" | "read-write" | "read_write" => permissions.write = true,
"c" | "command" => permissions.command = true,
_ => {
return Err(format!(
"unknown permission category `{category}`; use READ, WRITE, or COMMAND"
));
}
} }
} "w" | "write" | "read-write" | "read_write" => {
validate_permissions(permissions)?; server_api::ExternalWorkdirPermissions::WRITE
options.permissions = permissions; }
"c" | "command" => server_api::ExternalWorkdirPermissions::COMMAND,
_ => {
return Err(format!(
"unknown permission level `{permission_input}`; use READ, WRITE, or COMMAND"
));
}
};
} }
writeln!(output, "TTL: {}", ttl_label(options.ttl)) writeln!(output, "TTL: {}", ttl_label(options.ttl))
@@ -437,7 +457,7 @@ async fn resolve_share_options(
configure_interactively(options, &workspaces, &mut input, &mut output) configure_interactively(options, &workspaces, &mut input, &mut output)
} }
pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { pub async fn run(options: WorkdirShareOptions) -> Result<(), String> {
let Some(options) = resolve_share_options(options).await? else { let Some(options) = resolve_share_options(options).await? else {
eprintln!("External Workdir share cancelled; no grant was created."); eprintln!("External Workdir share cancelled; no grant was created.");
return Ok(()); return Ok(());
@@ -649,7 +669,7 @@ async fn serve_provider_connection(
.max_message_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES)) .max_message_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES))
.max_frame_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES)); .max_frame_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES));
let connection = connect_async_with_config(request, Some(websocket_config), false); let connection = connect_async_with_config(request, Some(websocket_config), false);
let (mut socket, _) = tokio::select! { let (socket, _) = tokio::select! {
signal = tokio::signal::ctrl_c() => { signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?; revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
@@ -671,11 +691,40 @@ async fn serve_provider_connection(
read_limits: BoundedReadLimits::EXTERNAL_DEFAULT, read_limits: BoundedReadLimits::EXTERNAL_DEFAULT,
}, },
}); });
let end =
serve_external_workdir_provider_socket(socket, registration, session, generation, async {
tokio::signal::ctrl_c()
.await
.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))
})
.await?;
if end == ProviderEnd::Interrupted {
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
}
Ok(end)
}
/// Run the production External Workdir provider protocol over an established WebSocket.
///
/// The CLI uses this pump after opening its authenticated Backend connection. Keeping the
/// protocol pump separate lets composed tests exercise the same registration, operation,
/// cancellation, and result-forwarding implementation without spawning a second CLI process.
pub async fn serve_external_workdir_provider_socket<S, I>(
mut socket: tokio_tungstenite::WebSocketStream<S>,
registration: ExternalWorkdirProviderFrame,
session: &LocalWorkdirSession,
generation: u64,
interrupt: I,
) -> Result<ProviderEnd, String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
I: std::future::Future<Output = Result<(), String>>,
{
tokio::pin!(interrupt);
send_provider_frame(&mut socket, &registration).await?; send_provider_frame(&mut socket, &registration).await?;
let registered = tokio::select! { let registered = tokio::select! {
signal = tokio::signal::ctrl_c() => { signal = &mut interrupt => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; signal?;
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
return Ok(ProviderEnd::Interrupted); return Ok(ProviderEnd::Interrupted);
} }
registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => { registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => {
@@ -685,18 +734,7 @@ async fn serve_provider_connection(
.map_err(|error| format!("External Workdir provider registration failed: {error}"))? .map_err(|error| format!("External Workdir provider registration failed: {error}"))?
} }
}; };
let Message::Text(registered) = registered else { validate_provider_registration(registered, generation)?;
return Err("Backend returned an invalid External Workdir registration frame".to_string());
};
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&registered)
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
if !matches!(
frame.message,
ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. }
if registered_generation == generation
) {
return Err("Backend rejected the External Workdir provider registration".to_string());
}
println!("Connection: online (generation {generation})"); println!("Connection: online (generation {generation})");
let (completion_sender, mut completions) = let (completion_sender, mut completions) =
@@ -704,10 +742,9 @@ async fn serve_provider_connection(
let mut operations = HashMap::<String, OperationTask>::new(); let mut operations = HashMap::<String, OperationTask>::new();
loop { loop {
tokio::select! { tokio::select! {
signal = tokio::signal::ctrl_c() => { signal = &mut interrupt => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; signal?;
stop_provider_operations(session, &mut operations, &mut completions).await?; stop_provider_operations(session, &mut operations, &mut completions).await?;
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
return Ok(ProviderEnd::Interrupted); return Ok(ProviderEnd::Interrupted);
} }
completion = completions.recv() => { completion = completions.recv() => {
@@ -916,7 +953,7 @@ async fn execute_operation(
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result }, Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
Err(message) => ExternalWorkdirOperationOutcome::Failed { Err(message) => ExternalWorkdirOperationOutcome::Failed {
error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError { error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
code: workdir::http::WorkdirTransportErrorCode::Unsupported, code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
message, message,
}), }),
}, },
@@ -1060,6 +1097,23 @@ mod tests {
} }
} }
#[test]
fn provider_registration_surfaces_backend_close_reason() {
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
let error = validate_provider_registration(
Message::Close(Some(CloseFrame {
code: CloseCode::Policy,
reason: "provider read limits source=1024 response=512; Backend requires source=2048 response=1024".into(),
})),
1,
)
.unwrap_err();
assert!(error.contains("provider read limits"), "{error}");
assert!(error.contains("Backend requires"), "{error}");
}
#[test] #[test]
fn interactive_share_defaults_to_first_workspace_read_only_and_unlimited() { fn interactive_share_defaults_to_first_workspace_read_only_and_unlimited() {
let workspaces = [workspace("workspace-a", "Alpha")]; let workspaces = [workspace("workspace-a", "Alpha")];
@@ -1085,12 +1139,12 @@ mod tests {
} }
#[test] #[test]
fn interactive_share_requires_explicit_write_and_command_selection_and_confirmation() { fn interactive_share_selects_one_hierarchical_level_and_requires_confirmation() {
let workspaces = [ let workspaces = [
workspace("workspace-a", "Alpha"), workspace("workspace-a", "Alpha"),
workspace("workspace-b", "Beta"), workspace("workspace-b", "Beta"),
]; ];
let mut input = std::io::Cursor::new(b"2\nREAD,WRITE,COMMAND\n15m\ny\n".to_vec()); let mut input = std::io::Cursor::new(b"2\nCOMMAND\n15m\ny\n".to_vec());
let mut output = Vec::new(); let mut output = Vec::new();
let selected = let selected =
@@ -1110,7 +1164,39 @@ mod tests {
assert_eq!(selected.ttl, Some(Duration::from_secs(900))); assert_eq!(selected.ttl, Some(Duration::from_secs(900)));
let output = String::from_utf8(output).unwrap(); let output = String::from_utf8(output).unwrap();
assert!(output.contains("not sandboxed")); assert!(output.contains("not sandboxed"));
assert!(output.contains("Permissions: READ, WRITE, COMMAND")); assert!(output.contains("Permissions: COMMAND (includes WRITE + READ)"));
}
#[test]
fn share_permission_levels_map_to_exact_existing_capability_bits() {
let bits = |permissions| {
serde_json::to_value(workdir_capabilities(permissions)).unwrap()["bits"]
.as_u64()
.unwrap()
};
assert_eq!(bits(server_api::ExternalWorkdirPermissions::READ), 25);
assert_eq!(bits(server_api::ExternalWorkdirPermissions::WRITE), 31);
assert_eq!(bits(server_api::ExternalWorkdirPermissions::COMMAND), 63);
for invalid in [
server_api::ExternalWorkdirPermissions {
read: false,
write: false,
command: true,
},
server_api::ExternalWorkdirPermissions {
read: false,
write: true,
command: false,
},
server_api::ExternalWorkdirPermissions {
read: true,
write: false,
command: true,
},
] {
assert!(validate_permissions(invalid).is_err());
}
} }
#[test] #[test]
+31 -16
View File
@@ -1,34 +1,49 @@
# External Workdir sharing permissions # External Workdir sharing permissions
`yoi workdir share` exposes a client-hosted directory through an authenticated, `yoi workdir share` exposes a client-hosted directory through an authenticated,
outbound Backend connection. The operator grants three independent categories: outbound Backend connection. The operator selects exactly one permission level:
- **READ**: file reads and read-only discovery operations such as Stat, List, - **READ** (capability bits `25`): file reads and read-only discovery operations
ViewImage, Glob, and Grep. such as Stat, List, ViewImage, Glob, and Grep.
- **WRITE**: Write and Edit. WRITE requires READ because updating an existing - **WRITE** (capability bits `31`): READ plus Write and Edit. Including READ
file uses the read-before-write content-hash contract. Selecting WRITE never preserves the read-before-write content-hash contract. WRITE never enables
enables COMMAND. COMMAND.
- **COMMAND**: command start, status, bounded output, and cancellation. COMMAND - **COMMAND** (capability bits `63`): WRITE plus READ plus command start, status,
does not enable READ or WRITE. It executes on the sharing host with the OS bounded output, and cancellation. It executes on the sharing host with the OS
authority of the user running the CLI; External Workdir command execution is authority of the user running the CLI; External Workdir command execution is
**not a sandbox** and does not confine the process to the shared directory. **not a sandbox** and does not confine the process to the shared directory.
READ is enabled by default and COMMAND is disabled by default. Interactive mode In short, `COMMAND ⇒ WRITE ⇒ READ`. READ is the default. Interactive mode asks
asks for a comma-separated category list and shows the exact grant before it is for one level and shows its included permissions before creating the grant. For
created. For automation, repeat `--permission`: automation, pass `--permission` once:
```sh ```sh
yoi --backend https://example.invalid workdir share ./project \ yoi --backend https://example.invalid workdir share ./project \
--workspace-id workspace-a \ --workspace-id workspace-a \
--permission read \
--permission write \
--permission command \ --permission command \
--non-interactive --non-interactive
``` ```
`--read-only` remains an alias for READ. `--read-write` remains an alias for `--read-only` remains an alias for the READ level. `--read-write` remains an
READ+WRITE and never enables COMMAND. These legacy flags conflict with explicit alias for the WRITE level and never enables COMMAND. These legacy flags conflict
`--permission` arguments so ambiguous automation fails closed. with `--permission`, and multiple `--permission` arguments are rejected so
ambiguous automation fails closed.
The grant API retains the boolean projection for compatibility, but accepts only
these combinations when creating new grants:
| Level | `read` | `write` | `command` |
|---|---:|---:|---:|
| READ | `true` | `false` | `false` |
| WRITE | `true` | `true` | `false` |
| COMMAND | `true` | `true` | `true` |
Older non-hierarchical grants such as command-only or READ+COMMAND are not
silently widened. The Backend refuses their projection, provider registration,
and subsequent restore/reconnect with guidance to revoke and re-share the
directory at one of the supported levels. General Workdir attachment/session
capability intersections may still produce partial capability sets; that
restriction applies only to External grant authority.
Omitting `--ttl` creates no automatic expiry. An explicit TTL must be between Omitting `--ttl` creates no automatic expiry. An explicit TTL must be between
60 seconds and 24 hours. Ctrl-C, revoke, expiry, provider disconnect, attachment 60 seconds and 24 hours. Ctrl-C, revoke, expiry, provider disconnect, attachment
+4 -4
View File
@@ -2092,15 +2092,15 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"command": { "command": {
"description": "Execute commands as the sharing CLI user on the provider host. This is\nexplicit authority and does not imply either filesystem category.", "description": "Execute commands as the sharing CLI user on the provider host. COMMAND\nincludes WRITE and READ, remains explicit, and is not sandboxed.",
"type": "boolean" "type": "boolean"
}, },
"read": { "read": {
"description": "Read files and use read-only discovery operations such as Glob and Grep.", "description": "Read files and use read-only discovery operations such as Glob and Grep.\nEvery supported External Workdir permission level enables this field.",
"type": "boolean" "type": "boolean"
}, },
"write": { "write": {
"description": "Create or update files with Write and Edit. Existing-file mutation also\nrequires `read` so callers can satisfy the read-before-write contract.", "description": "Create or update files with Write and Edit. WRITE includes READ so callers\ncan satisfy the read-before-write contract.",
"type": "boolean" "type": "boolean"
} }
}, },
@@ -14020,7 +14020,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c" "x-yoi-source-digest": "sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -0,0 +1,224 @@
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
const port = Number(Deno.args[0]);
const buildRoot = Deno.args[1];
if (!Number.isInteger(port) || !buildRoot) {
throw new Error("usage: console_history_fixture_server.ts <port> <build-root>");
}
const workspaceId = "console-history-review";
const runtimeId = "runtime-a";
const workerId = "worker-a";
const workerResourceKey = "W-900";
let historyRequests = 0;
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
const worker = {
worker_id: workerId,
runtime_id: runtimeId,
resource_key: workerResourceKey,
display_name: "Console Fixture",
label: "Console Fixture",
availability: "observed",
state: "stopped",
retention_state: "retained",
host_id: "fixture-host",
profile: "default",
tags: [],
diagnostics: [],
capabilities: { can_stop: false, can_spawn_followup: false },
workspace: {
workspace_id: workspaceId,
visibility: "workspace",
identity: workspaceId,
},
implementation: { kind: "builtin", display_hint: "fixture" },
workdir_attachments: [],
};
function historyEntry(index: number, role: "user" | "assistant") {
const user = role === "user";
return {
entry_id: `${role}-${index}`,
timestamp: index,
provenance: user ? "human_input" : "model_output",
kind: "message",
role,
content: [{
kind: "text",
text: user
? `question ${index}`
: `done ${index}\ndetail ${index}.1\ndetail ${index}.2\ndetail ${index}.3`,
}],
};
}
function historyPage(indices: number[], cursor: string | null) {
return {
availability: "page",
page: {
session_id: "session-a",
lineage_id: "lineage-a",
compact_ancestor_lineage_ids: [],
turns: indices.map((index) => ({
turn_id: `user-${index}`,
entries: [historyEntry(index, "user"), historyEntry(index, "assistant")],
})),
next_cursor: cursor,
has_more: cursor !== null,
},
};
}
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
};
async function staticResponse(pathname: string): Promise<Response> {
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
const normalized = normalize(relative);
if (normalized.startsWith("..")) return new Response("not found", { status: 404 });
let filePath = join(buildRoot, normalized);
try {
const stat = await Deno.stat(filePath);
if (stat.isDirectory) filePath = join(filePath, "index.html");
return new Response(await Deno.readFile(filePath), {
headers: { "content-type": mime[extname(filePath)] ?? "application/octet-stream" },
});
} catch {
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
}
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
if (url.pathname === "/fixture-state") return json({ history_requests: historyRequests });
if (url.pathname === "/api/workspaces") {
return json([{
workspace_id: workspaceId,
owner_account_id: "owner",
display_name: "Console History Review",
state: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
}]);
}
if (url.pathname === `/api/w/${workspaceId}/workspace`) {
return json({
workspace_id: workspaceId,
display_name: "Console History Review",
record_authority: "fixture",
schema_version: 1,
auth: {
Passkey: {
rp_id: "127.0.0.1",
origin: `http://127.0.0.1:${port}`,
public_base_url: `http://127.0.0.1:${port}`,
cookie_name: "fixture",
},
},
permissions: {
manage_repositories: false,
manage_secrets: false,
manage_runtimes: false,
delete_workspace: false,
},
extension_points: {
store: "fixture",
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
},
});
}
if (url.pathname === `/api/w/${workspaceId}/repositories`) {
return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] });
}
if (url.pathname === `/api/w/${workspaceId}/working-directories`) {
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
}
if (
url.pathname === `/api/w/${workspaceId}/workers/${workerResourceKey}` ||
url.pathname === `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}`
) {
return json(worker);
}
if (
url.pathname ===
`/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session`
) {
return json({
availability: "retained_snapshot",
identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 12 },
snapshot: {
pending_submissions: {
revision: 0,
notification_count: 0,
head_id: null,
submissions: [],
},
entries: [7, 8, 9, 10, 11, 12].flatMap((index) => [
historyEntry(index, "user"),
historyEntry(index, "assistant"),
]),
},
});
}
if (
url.pathname ===
`/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session/history`
) {
historyRequests += 1;
switch (url.searchParams.get("cursor")) {
case null:
return json(historyPage([8, 9, 10, 11, 12], "older-8"));
case "older-8":
return json(historyPage([3, 4, 5, 6, 7], "older-3"));
case "older-3":
return json(historyPage([1, 2], null));
default:
return json({ availability: "unavailable", reason: "invalid_cursor", message: "invalid" });
}
}
if (
url.pathname === `/api/w/${workspaceId}/protocol/ws` &&
request.headers.get("upgrade") === "websocket"
) {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onmessage = (event) => {
const frame = JSON.parse(String(event.data));
if (frame?.message?.method !== "subscribe_events") return;
const requestId = frame.message.params.request_id;
socket.send(JSON.stringify({
protocol_version: 1,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: requestId,
subscription_id: "fixture-workers",
selector: frame.message.params.selector,
snapshot_revision: 1,
snapshot: { topic: "workers", data: { workers: [] } },
},
},
}));
};
return response;
}
return await staticResponse(url.pathname);
});
@@ -0,0 +1,217 @@
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium } from "playwright";
const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const workspaceRoot = join(repositoryRoot, "web/workspace");
const fixtureServer = join(
repositoryRoot,
"tools/web-ux/browser-tests/console_history_fixture_server.ts",
);
const workspaceId = "console-history-review";
async function freePort(): Promise<number> {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
async function waitForServer(url: string): Promise<void> {
for (let attempt = 0; attempt < 200; attempt += 1) {
try {
if ((await fetch(url)).ok) return;
} catch {
// Retry until the bounded deadline while the owned server starts.
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`fixture server did not become ready: ${url}`);
}
async function historyRequestCount(baseUrl: string): Promise<number> {
const response = await fetch(`${baseUrl}/fixture-state`);
const state = await response.json();
return state.history_requests;
}
Deno.test("production Console pages backward by real turns while preserving both scroll anchors", async () => {
const build = await new Deno.Command(Deno.execPath(), {
args: ["task", "build"],
cwd: workspaceRoot,
stdout: "piped",
stderr: "piped",
}).output();
if (!build.success) {
throw new Error(
`Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`,
);
}
const port = await freePort();
const baseUrl = `http://127.0.0.1:${port}`;
const server = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--allow-net",
"--allow-read",
fixtureServer,
String(port),
join(workspaceRoot, "build"),
],
stdout: "null",
stderr: "null",
}).spawn();
try {
await waitForServer(`${baseUrl}/health`);
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 600 },
colorScheme: "dark",
});
const page = await context.newPage();
const errors: string[] = [];
const responses: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("pageerror", (error) => errors.push(String(error)));
page.on("response", (response) => {
if (response.status() >= 400 || response.url().includes("/api/")) {
responses.push(`${response.status()} ${response.url()}`);
}
});
await page.goto(
`${baseUrl}/w/${workspaceId}/workers/W-900-console-fixture/console`,
);
const transcript = page.getByRole("article", { name: "main transcript" });
try {
await transcript.getByText("question 8", { exact: true }).waitFor({ timeout: 5_000 });
} catch (error) {
throw new Error(
`Console did not render retained history: ${error}\nresponses=${
JSON.stringify(responses)
}\nerrors=${JSON.stringify(errors)}\nbody=${await page.locator("body").innerText()}`,
);
}
const navigation = page.getByRole("complementary", { name: "Conversation turns" });
await navigation.getByText("question 7", { exact: true }).waitFor();
assertEquals(await historyRequestCount(baseUrl), 1);
assertEquals(await transcript.getByText(/^question (7|8|9|10|11|12)$/).count(), 6);
assertEquals(await navigation.getByText(/^question (7|8|9|10|11|12)$/).count(), 6);
const currentQuestionTop = await transcript
.getByText("question 7", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
const retainedQuestionTop = await transcript
.getByText("question 8", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
assert(
currentQuestionTop < retainedQuestionTop,
"the unmatched current-snapshot turn must precede overlapping retained history",
);
const question12 = navigation.getByRole("button", {
name: "Jump to conversation: question 12",
});
assertEquals(await question12.getByText("detail 12.1", { exact: true }).count(), 1);
assertEquals(await question12.getByText("detail 12.2", { exact: true }).count(), 1);
assertEquals(await question12.getByText("detail 12.3", { exact: true }).count(), 0);
assert(!(await question12.textContent())?.includes("User"));
assert(!(await question12.textContent())?.includes("AI"));
const consoleScroll = transcript.locator("xpath=..");
const topAlignment = await page.evaluate(() => {
const document = (globalThis as any).document;
const body = document.querySelector(".console-scroll")?.getBoundingClientRect();
const turns = document.querySelector(".turn-navigation")?.getBoundingClientRect();
return body && turns ? Math.abs(body.top - turns.top) : Number.POSITIVE_INFINITY;
});
assert(topAlignment <= 1, `transcript and turn bar top edges differ by ${topAlignment}px`);
const navigationAnchoredTop = await navigation
.locator('[data-turn-id="user-8"]')
.evaluate((element) => element.getBoundingClientRect().top);
const anchoredTop = await consoleScroll.evaluate((element) => {
element.scrollTop = 0;
const anchor = element.querySelector('[data-console-line-id*="user-8"]');
const top = anchor?.getBoundingClientRect().top ?? Number.NaN;
element.dispatchEvent(new Event("scroll"));
return top;
});
await transcript.getByText("question 3", { exact: true }).waitFor();
await navigation.getByText("question 3", { exact: true }).waitFor();
const restoredTop = await transcript
.locator('[data-console-line-id*="user-8"]')
.evaluate((element) => element.getBoundingClientRect().top);
assert(
Math.abs(restoredTop - anchoredTop) <= 1,
`transcript anchor moved from ${anchoredTop}px to ${restoredTop}px`,
);
await page.waitForTimeout(250);
assertEquals(
await historyRequestCount(baseUrl),
2,
"a stationary top sentinel must not drain another page",
);
const turnList = navigation.locator(".turn-list");
const navigationAfterPrepend = await navigation
.locator('[data-turn-id="user-8"]')
.evaluate((element) => element.getBoundingClientRect().top);
assert(
Math.abs(navigationAfterPrepend - navigationAnchoredTop) <= 1,
`turn-bar anchor moved from ${navigationAnchoredTop}px to ${navigationAfterPrepend}px`,
);
await turnList.evaluate((element) => {
element.scrollTop = Math.max(2, element.scrollTop);
element.dispatchEvent(new Event("scroll"));
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await transcript.getByText("question 1", { exact: true }).waitFor();
await navigation.getByText("question 1", { exact: true }).waitFor();
assertEquals(await historyRequestCount(baseUrl), 3);
assertEquals(await page.getByText("Start of conversation", { exact: true }).count(), 2);
await navigation.getByRole("button", {
name: "Jump to conversation: question 3",
}).click();
await page.waitForFunction(() => {
const document = (globalThis as any).document;
const element = document.querySelector('[data-console-line-id*="user-3"]');
const parent = element?.closest(".console-scroll");
if (!element || !parent) return false;
const item = element.getBoundingClientRect();
const viewport = parent.getBoundingClientRect();
return item.top >= viewport.top - 1 && item.top < viewport.bottom;
});
const jumpGeometry = await transcript
.locator('[data-console-line-id*="user-3"]')
.evaluate((element) => {
const parent = element.closest(".console-scroll")!;
const item = element.getBoundingClientRect();
const viewport = parent.getBoundingClientRect();
return { itemTop: item.top, viewportTop: viewport.top, viewportBottom: viewport.bottom };
});
assert(jumpGeometry.itemTop >= jumpGeometry.viewportTop - 1);
assert(jumpGeometry.itemTop < jumpGeometry.viewportBottom);
const centering = await question12.evaluate((button) => {
const turn = button.getBoundingClientRect();
const bar = button.querySelector(".bar")!.getBoundingClientRect();
return Math.abs((turn.top + turn.bottom) / 2 - (bar.top + bar.bottom) / 2);
});
assert(centering <= 1, `turn marker is not vertically centered (${centering}px)`);
assertEquals(errors, []);
await context.close();
} finally {
await browser.close();
}
} finally {
server.kill("SIGTERM");
await server.status;
}
});
@@ -0,0 +1,149 @@
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
const port = Number(Deno.args[0]);
const buildRoot = Deno.args[1];
const sectionCount = Deno.args[2] === undefined ? 90 : Number(Deno.args[2]);
if (
!Number.isInteger(port) || !buildRoot || !Number.isInteger(sectionCount) || sectionCount < 3 ||
sectionCount > 100
) {
throw new Error("usage: server.ts <port> <build-root> [section-count: 3..100]");
}
const workspaceId = "memory-review";
const paragraphs = Array.from(
{ length: sectionCount },
(_, index) =>
`## Section ${
index + 1
}\n\nThis is representative Memory content with **important context**, an identifier \`memory-item-${
index + 1
}\`, and a long URL https://example.com/${"long-segment-".repeat(12)}${
index + 1
}.\n\n> A durable note for section ${index + 1}.\n\n- first detail\n- second detail`,
).join("\n\n");
const bodyMd =
`# Workspace Memory\n\nThis document is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${
"wide-value-".repeat(16)
} | stable | keep scrolling local |\n\n##### Deep document heading\n\n###### Deepest document heading\n\n\`\`\`rust\nfn example() { println!("${
"wide-code-".repeat(18)
}"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n<img src=x onerror=alert(1)>\n\n${paragraphs}\n\n## T-661 UNIQUE END MARKER`;
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
};
async function staticResponse(pathname: string): Promise<Response> {
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
const normalized = normalize(relative);
if (normalized.startsWith("..")) return new Response("not found", { status: 404 });
let filePath = join(buildRoot, normalized);
try {
const stat = await Deno.stat(filePath);
if (stat.isDirectory) filePath = join(filePath, "index.html");
return new Response(await Deno.readFile(filePath), {
headers: { "content-type": mime[extname(filePath)] ?? "application/octet-stream" },
});
} catch {
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
}
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
if (url.pathname === "/api/workspaces") {
return json([{
workspace_id: workspaceId,
owner_account_id: "owner",
display_name: "Memory Review",
state: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
}]);
}
if (url.pathname === `/api/w/${workspaceId}/workspace`) {
return json({
workspace_id: workspaceId,
display_name: "Memory Review",
record_authority: "fixture",
schema_version: 1,
auth: {
Passkey: {
rp_id: "127.0.0.1",
origin: `http://127.0.0.1:${port}`,
public_base_url: `http://127.0.0.1:${port}`,
cookie_name: "fixture",
},
},
permissions: {
manage_repositories: false,
manage_secrets: false,
manage_runtimes: false,
delete_workspace: false,
},
extension_points: {
store: "fixture",
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
},
});
}
if (url.pathname === `/api/w/${workspaceId}/repositories`) {
return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] });
}
if (url.pathname === `/api/w/${workspaceId}/working-directories`) {
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
}
if (url.pathname === `/api/w/${workspaceId}/memory`) {
return json({
body_md: bodyMd,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
bytes: new TextEncoder().encode(bodyMd).byteLength,
record_source: "sqlite_workspace_authority.memory_document",
});
}
if (
url.pathname === `/api/w/${workspaceId}/protocol/ws` &&
request.headers.get("upgrade") === "websocket"
) {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onmessage = (event) => {
const frame = JSON.parse(String(event.data));
if (frame?.message?.method !== "subscribe_events") return;
const requestId = frame.message.params.request_id;
socket.send(JSON.stringify({
protocol_version: 1,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: requestId,
subscription_id: "fixture-workers",
selector: { topic: "workspace_workers" },
snapshot_revision: 1,
snapshot: { topic: "workers", data: { workers: [] } },
},
},
}));
};
return response;
}
return await staticResponse(url.pathname);
});
@@ -0,0 +1,251 @@
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium, type Page } from "playwright";
const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const workspaceRoot = join(repositoryRoot, "web/workspace");
const fixtureServer = join(
repositoryRoot,
"tools/web-ux/browser-tests/memory_document_fixture_server.ts",
);
const workspaceId = "memory-review";
async function freePort(): Promise<number> {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
async function waitForServer(url: string): Promise<void> {
for (let attempt = 0; attempt < 200; attempt += 1) {
try {
if ((await fetch(url)).ok) return;
} catch {
// Retry until the bounded deadline while the owned server starts.
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`fixture server did not become ready: ${url}`);
}
async function mainScrollState(page: Page) {
return await page.locator("main").evaluate((main) => ({
scrollTop: main.scrollTop,
scrollHeight: main.scrollHeight,
clientHeight: main.clientHeight,
}));
}
async function markerIsInsideMain(page: Page): Promise<boolean> {
return await page.evaluate(() => {
const document = (globalThis as any).document;
const main = document.querySelector("main");
const marker = [...document.querySelectorAll("h2")].find((heading) =>
heading.textContent === "T-661 UNIQUE END MARKER"
);
if (!main || !marker) return false;
const mainRect = main.getBoundingClientRect();
const markerRect = marker.getBoundingClientRect();
return markerRect.top >= mainRect.top && markerRect.bottom <= mainRect.bottom;
});
}
async function openMemoryPage(
page: Page,
baseUrl: string,
mobile = false,
): Promise<void> {
await page.goto(`${baseUrl}/w/${workspaceId}/memory`);
const heading = page.locator("h1", { hasText: "Workspace Memory" });
await heading.waitFor({ state: "attached" });
if (mobile) await page.getByRole("button", { name: "Hide sidebar" }).click();
await heading.waitFor();
}
async function swipeMainToEnd(page: Page): Promise<void> {
const session = await page.context().newCDPSession(page);
for (let attempt = 0; attempt < 100; attempt += 1) {
const state = await mainScrollState(page);
if (state.scrollTop + state.clientHeight >= state.scrollHeight - 1) return;
await session.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: 300, y: 700, id: 1 }],
});
for (const y of [580, 460, 340, 220, 100]) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: 300, y, id: 1 }],
});
}
await session.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await page.waitForTimeout(20);
}
throw new Error("touch input did not reach the end of the Memory document");
}
Deno.test("production Memory shell renders safe Markdown and scrolls to its end by wheel, keyboard, and touch", async () => {
const build = await new Deno.Command(Deno.execPath(), {
args: ["task", "build"],
cwd: workspaceRoot,
stdout: "piped",
stderr: "piped",
}).output();
if (!build.success) {
throw new Error(
`Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`,
);
}
const port = await freePort();
const baseUrl = `http://127.0.0.1:${port}`;
const server = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--allow-net",
"--allow-read",
fixtureServer,
String(port),
join(workspaceRoot, "build"),
],
stdout: "null",
stderr: "null",
}).spawn();
try {
await waitForServer(`${baseUrl}/health`);
const browser = await chromium.launch({ headless: true });
try {
for (
const scenario of [
{ label: "wheel", width: 1440, height: 900, colorScheme: "light" as const },
{ label: "keyboard", width: 768, height: 900, colorScheme: "dark" as const },
{
label: "touch",
width: 390,
height: 844,
colorScheme: "light" as const,
mobile: true,
},
]
) {
const context = await browser.newContext({
viewport: { width: scenario.width, height: scenario.height },
colorScheme: scenario.colorScheme,
hasTouch: scenario.mobile ?? false,
isMobile: scenario.mobile ?? false,
});
const page = await context.newPage();
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("pageerror", (error) => errors.push(String(error)));
await openMemoryPage(page, baseUrl, scenario.mobile);
const initial = await mainScrollState(page);
assert(initial.scrollHeight > initial.clientHeight);
assertEquals(await markerIsInsideMain(page), false);
assertEquals(
await page.evaluate(() => {
const document = (globalThis as any).document;
return document.documentElement.scrollWidth === document.documentElement.clientWidth;
}),
true,
);
if (scenario.label === "wheel") {
assertEquals(await page.locator("h1").allTextContents(), ["Workspace Memory"]);
assertEquals(
await page.locator("strong").filter({ hasText: "important context" }).count() > 0,
true,
);
assertEquals(await page.locator("blockquote").count() > 0, true);
assertEquals(await page.locator("ul li").count() > 0, true);
for (
const heading of [
{ selector: "h5", text: "Deep document heading" },
{ selector: "h6", text: "Deepest document heading" },
]
) {
const element = page.locator(heading.selector, { hasText: heading.text });
assertEquals(await element.count(), 1);
assert(
await element.evaluate((node) =>
Number.parseFloat((globalThis as any).getComputedStyle(node).fontSize) >= 12
),
`${heading.selector} fell below the design language minimum text size`,
);
}
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
assertEquals(await page.locator("article img, article script").count(), 0);
assertEquals(await page.getByRole("heading", { name: "Memory Document" }).count(), 0);
for (const label of ["Markdown table", "rust code block"]) {
const region = page.getByRole("region", { name: label });
assertEquals(await region.getAttribute("tabindex"), "0");
assert(await region.evaluate((element) => element.scrollWidth > element.clientWidth));
}
await page.locator("main").hover();
await page.mouse.wheel(0, 100_000);
} else if (scenario.label === "keyboard") {
await page.getByText("Document details", { exact: true }).focus();
await page.keyboard.press("End");
} else {
await swipeMainToEnd(page);
}
await page.waitForTimeout(500);
const afterInteraction = await mainScrollState(page);
assert(
afterInteraction.scrollTop > 0,
`${scenario.label} did not move the main scroll owner: ${
JSON.stringify(afterInteraction)
}`,
);
assertEquals(await markerIsInsideMain(page), true);
assertEquals(errors, []);
await context.close();
}
const stateContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const emptyPage = await stateContext.newPage();
await emptyPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
body_md: " \n",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
bytes: 2,
record_source: "fixture",
}),
});
});
await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
await emptyPage.getByText("Memory document is empty.").waitFor();
assertEquals(await emptyPage.getByRole("alert").count(), 0);
const errorPage = await stateContext.newPage();
await errorPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
});
await errorPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
const alert = errorPage.getByRole("alert");
await alert.waitFor();
assert((await alert.textContent())?.includes("Memory document unavailable."));
assertEquals(await errorPage.getByText("Memory document is empty.").count(), 0);
await stateContext.close();
} finally {
await browser.close();
}
} finally {
server.kill("SIGTERM");
await server.status;
}
});
+1 -1
View File
@@ -11,7 +11,7 @@
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts" "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts"
}, },
"fmt": { "fmt": {
"lineWidth": 100, "lineWidth": 100,
+1
View File
@@ -96,6 +96,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration. - `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
- `src/lib/workspace/header/`: Header frame, context, and overrides. - `src/lib/workspace/header/`: Header frame, context, and overrides.
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack. - `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
- `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`. - `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`.
- `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom. - `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom.
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview", "preview": "deno run -A npm:vite@7.2.7 preview",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
File diff suppressed because one or more lines are too long
+32 -1
View File
@@ -149,6 +149,33 @@ entry_id: string,
*/ */
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, }); timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
export type SessionConversationTurn = { turn_id: string, entries: Array<SessionSnapshotEntry>, };
export type SessionHistoryLineageBoundary = { lineage_id: string,
/**
* Canonical public form of the final user-visible turn at the exact
* provider boundary. Entries after that boundary are excluded.
*/
adopted_through_turn?: SessionConversationTurn | null, };
export type SessionHistoryPage = { session_id: string,
/**
* Stable identity of the adopted active lineage used to fence stale pages.
*/
lineage_id: string,
/**
* Older lineage identities that the active lineage fully adopted through
* one or more consecutive Compact rotations. Clients may retain already
* loaded turns when their prior lineage appears here, while replacing the
* cursor with this page's boundary.
*/
compact_ancestor_lineage_ids?: Array<string>,
/**
* Immediate parent relationship used to retain only the adopted prefix
* across fork/rewind refreshes that have no newest-page overlap.
*/
parent_lineage?: SessionHistoryLineageBoundary | null, turns: Array<SessionConversationTurn>, next_cursor?: string | null, has_more: boolean, };
export type PendingSubmissionSummary = { submission_id: string, export type PendingSubmissionSummary = { submission_id: string,
/** /**
* Single-line display text, at most 240 Unicode scalars plus an ellipsis. * Single-line display text, at most 240 Unicode scalars plus an ellipsis.
@@ -306,7 +333,11 @@ export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "req
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } }; export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number, export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": {
/**
* Stable durable history identity. Older mixed-version producers omit it.
*/
entry_id?: string | null, segments: Array<Segment>, } } | { "event": "session_entry_committed", "data": { entry: SessionSnapshotEntry, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
/** /**
* The attempt that just failed. 1 origin. * The attempt that just failed. 1 origin.
*/ */
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit. // @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts // Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -182,7 +182,9 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
); );
const kind = stringField(source, "kind"); const kind = stringField(source, "kind");
if (kind === "repository") { if (kind === "repository") {
if (source.grant_id !== undefined || source.grant_permissions !== undefined) { if (
source.grant_id !== undefined || source.grant_permissions !== undefined
) {
throw new Error( throw new Error(
"Repository Workdir source must not contain External grant authority", "Repository Workdir source must not contain External grant authority",
); );
@@ -201,7 +203,7 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
return { return {
kind, kind,
grant_id: stringField(source, "grant_id"), grant_id: stringField(source, "grant_id"),
grant_permissions: parseExternalWorkdirPermissions( grant_permissions: parseExternalWorkdirGrantPermissions(
source.grant_permissions, source.grant_permissions,
"External Workdir grant permissions", "External Workdir grant permissions",
), ),
@@ -226,6 +228,21 @@ export function parseExternalWorkdirPermissions(
}; };
} }
export function parseExternalWorkdirGrantPermissions(
value: unknown,
label = "External Workdir grant permissions",
): ExternalWorkdirPermissions {
const permissions = parseExternalWorkdirPermissions(value, label);
const hierarchical = permissions.read &&
(!permissions.command || permissions.write);
if (!hierarchical) {
throw new Error(
`${label} must be READ=(true,false,false), WRITE=(true,true,false), or COMMAND=(true,true,true)`,
);
}
return permissions;
}
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget { function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
const record = exactRecord( const record = exactRecord(
value, value,
@@ -106,7 +106,7 @@ Deno.test("Worker summary parser preserves attachment-effective External permiss
source: { source: {
kind: "external_grant", kind: "external_grant",
grant_id: "grant-1", grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: true }, grant_permissions: { read: true, write: true, command: true },
}, },
materializer_kind: "client_hosted_external", materializer_kind: "client_hosted_external",
status: "active", status: "active",
@@ -4,15 +4,15 @@ import { cleanup, fireEvent, render } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import ConsoleTurnNavigation from "./ConsoleTurnNavigation.svelte"; import ConsoleTurnNavigation from "./ConsoleTurnNavigation.svelte";
const turns = [ const items = [
{ id: "u1", user: "First question", assistant: "First answer" }, { turnId: "u1", lineId: "u1", user: "First question", assistant: ["First answer"] },
{ id: "u2", user: "Second question", assistant: "" }, { turnId: "u2", lineId: "u2", user: "Second question", assistant: [] },
]; ];
afterEach(cleanup); afterEach(cleanup);
test("renders one bar per turn and previews both roles on hover", async () => { test("renders one bar per turn and previews both roles on hover", async () => {
const view = render(ConsoleTurnNavigation, { turns, onSelect: vi.fn() }); const view = render(ConsoleTurnNavigation, { items, onTurnClick: vi.fn() });
expect(view.getAllByRole("button")).toHaveLength(2); expect(view.getAllByRole("button")).toHaveLength(2);
expect(view.queryByRole("tooltip")).toBeNull(); expect(view.queryByRole("tooltip")).toBeNull();
const button = view.getAllByRole("button")[0]; const button = view.getAllByRole("button")[0];
@@ -25,14 +25,14 @@ test("renders one bar per turn and previews both roles on hover", async () => {
expect(view.queryByRole("tooltip")).toBeNull(); expect(view.queryByRole("tooltip")).toBeNull();
}); });
test("focus previews unanswered turns, click selects the user anchor, Escape dismisses", async () => { test("focus previews unanswered items, click selects the user anchor, Escape dismisses", async () => {
const onSelect = vi.fn(); const onTurnClick = vi.fn();
const view = render(ConsoleTurnNavigation, { turns, onSelect }); const view = render(ConsoleTurnNavigation, { items, onTurnClick });
const button = view.getAllByRole("button")[1]; const button = view.getAllByRole("button")[1];
await fireEvent.focus(button); await fireEvent.focus(button);
expect(view.getByRole("tooltip").textContent).toContain("No response yet"); expect(view.getByRole("tooltip").textContent).toContain("No response yet");
await fireEvent.click(button); await fireEvent.click(button);
expect(onSelect).toHaveBeenCalledWith("u2"); expect(onTurnClick).toHaveBeenCalledWith(items[1]);
await fireEvent.keyDown(window, { key: "Escape" }); await fireEvent.keyDown(window, { key: "Escape" });
expect(view.queryByRole("tooltip")).toBeNull(); expect(view.queryByRole("tooltip")).toBeNull();
await fireEvent.blur(button); await fireEvent.blur(button);
@@ -43,21 +43,21 @@ test("focus previews unanswered turns, click selects the user anchor, Escape dis
}); });
test("updates the visible response in place and removes stale previews on history replacement", async () => { test("updates the visible response in place and removes stale previews on history replacement", async () => {
const onSelect = vi.fn(); const onTurnClick = vi.fn();
const view = render(ConsoleTurnNavigation, { turns, onSelect }); const view = render(ConsoleTurnNavigation, { items, onTurnClick });
await fireEvent.mouseEnter(view.getAllByRole("button")[1]); await fireEvent.mouseEnter(view.getAllByRole("button")[1]);
await view.rerender({ turns: [turns[0], { ...turns[1], assistant: "Streaming answer" }], onSelect }); await view.rerender({ items: [items[0], { ...items[1], assistant: ["Streaming answer"] }], onTurnClick });
expect(view.getByRole("tooltip").textContent).toContain("Streaming answer"); expect(view.getByRole("tooltip").textContent).toContain("Streaming answer");
expect(view.getAllByRole("button")).toHaveLength(2); expect(view.getAllByRole("button")).toHaveLength(2);
await view.rerender({ turns: [], onSelect }); await view.rerender({ items: [], onTurnClick });
expect(view.queryByRole("tooltip")).toBeNull(); expect(view.queryByRole("tooltip")).toBeNull();
expect(view.queryAllByRole("button")).toHaveLength(0); expect(view.queryAllByRole("button")).toHaveLength(0);
}); });
test("preview content stays text and scrolling the rail dismisses its old position", async () => { test("preview content stays text and scrolling the rail dismisses its old position", async () => {
const view = render(ConsoleTurnNavigation, { const view = render(ConsoleTurnNavigation, {
turns: [{ id: "u", user: "<img src=x onerror=alert(1)>", assistant: "<script>bad()</script>" }], items: [{ turnId: "u", lineId: "u", user: "<img src=x onerror=alert(1)>", assistant: ["<script>bad()</script>"] }],
onSelect: vi.fn(), onTurnClick: vi.fn(),
}); });
await fireEvent.mouseEnter(view.getByRole("button")); await fireEvent.mouseEnter(view.getByRole("button"));
const tooltip = view.getByRole("tooltip"); const tooltip = view.getByRole("tooltip");
@@ -1,9 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { ConsoleTurn } from './turn-navigation'; import type { TurnNavigationItem } from './turn-navigation';
let { turns, onSelect }: { let {
turns: ConsoleTurn[]; items, onTurnClick, hasMore = false, loading = false, error = null,
onSelect: (id: string) => void; onLoadMore = () => {}, onTopEdgeChange = () => {}, historyAvailable = true,
element = $bindable(null),
}: {
items: TurnNavigationItem[];
onTurnClick: (item: TurnNavigationItem) => void;
hasMore?: boolean;
loading?: boolean;
error?: string | null;
onLoadMore?: () => void;
onTopEdgeChange?: (atTop: boolean) => void;
historyAvailable?: boolean;
element?: HTMLElement | null;
} = $props(); } = $props();
const tooltipId = $props.id(); const tooltipId = $props.id();
@@ -14,8 +25,8 @@
let railHeight = $state(0); let railHeight = $state(0);
let previewHeight = $state(0); let previewHeight = $state(0);
let rail: HTMLElement; let rail: HTMLElement;
const preview = $derived(dismissed ? undefined : turns.find( const preview = $derived(dismissed ? undefined : items.find(
(turn) => turn.id === (hoveredId ?? focusedId), (turn) => turn.turnId === (hoveredId ?? focusedId),
)); ));
const previewTop = $derived(Math.max(0, Math.min(anchorY, railHeight - previewHeight))); const previewTop = $derived(Math.max(0, Math.min(anchorY, railHeight - previewHeight)));
@@ -37,18 +48,35 @@
bind:clientHeight={railHeight} bind:clientHeight={railHeight}
onmouseleave={() => { hoveredId = null; }} onmouseleave={() => { hoveredId = null; }}
> >
<div class="turn-bars" onscroll={() => { dismissed = true; }}> <div class="turn-bars turn-list" bind:this={element} onscroll={(event) => {
{#each turns as turn, index (turn.id)} dismissed = true;
onTopEdgeChange(event.currentTarget.scrollTop <= 1);
}}>
<div class="history-boundary">
{#if !historyAvailable}
<span title="Earlier history is unavailable for this view" aria-label="Earlier history is unavailable for this view">·</span>
{:else if loading}
<button type="button" disabled aria-label="Loading earlier conversation…">…</button>
{:else if error}
<button type="button" onclick={onLoadMore} title={error} aria-label="Retry earlier conversation">↻</button>
{:else if hasMore}
<button type="button" onclick={onLoadMore} title="Earlier conversation available" aria-label="Earlier conversation available">↑</button>
{:else}
<span title="Start of conversation" aria-label="Start of conversation">·</span>
{/if}
</div>
{#each items as turn, index (turn.turnId)}
<button <button
type="button" type="button"
class="turn-button" class="turn-button"
class:previewing={preview?.id === turn.id} data-turn-id={turn.turnId}
class:previewing={preview?.turnId === turn.turnId}
aria-label={`Turn ${index + 1}: ${turn.user || 'User message'}`} aria-label={`Turn ${index + 1}: ${turn.user || 'User message'}`}
aria-describedby={preview?.id === turn.id ? tooltipId : undefined} aria-describedby={preview?.turnId === turn.turnId ? tooltipId : undefined}
onmouseenter={(event) => show(event, turn.id)} onmouseenter={(event) => show(event, turn.turnId)}
onfocus={(event) => show(event, turn.id)} onfocus={(event) => show(event, turn.turnId)}
onblur={() => { focusedId = null; }} onblur={() => { focusedId = null; }}
onclick={() => onSelect(turn.id)} onclick={() => onTurnClick(turn)}
><span class="turn-bar" aria-hidden="true"></span></button> ><span class="turn-bar" aria-hidden="true"></span></button>
{/each} {/each}
</div> </div>
@@ -60,7 +88,7 @@
> >
<div class="turn-preview" id={tooltipId} role="tooltip"> <div class="turn-preview" id={tooltipId} role="tooltip">
<p class="preview-user">{preview.user || '—'}</p> <p class="preview-user">{preview.user || '—'}</p>
<p class="preview-assistant">{preview.assistant || 'No response yet'}</p> <p class="preview-assistant">{preview.assistant.join(" ") || 'No response yet'}</p>
</div> </div>
</div> </div>
{/if} {/if}
@@ -86,7 +114,19 @@
.turn-bars::-webkit-scrollbar { display: none; } .turn-bars::-webkit-scrollbar { display: none; }
/* Center short lists without making overflowing turns unreachable. */ /* Center short lists without making overflowing turns unreachable. */
.turn-button:first-child { margin-top: auto; } .history-boundary { margin-top: auto; }
.history-boundary button, .history-boundary span {
display: grid;
place-items: center;
width: 32px;
min-height: 24px;
padding: var(--space-1);
border: 0;
background: transparent;
color: var(--text-muted);
font: inherit;
}
.history-boundary button { cursor: pointer; color: var(--tui-cyan); }
.turn-button:last-child { margin-bottom: auto; } .turn-button:last-child { margin-bottom: auto; }
.turn-button { .turn-button {
@@ -20,13 +20,9 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import SvelteMarkdown, { import type { Renderers } from "@humanspeak/svelte-markdown";
buildUnsupportedHTML,
type Renderers,
type SvelteMarkdownProps,
} from "@humanspeak/svelte-markdown";
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki"; import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
import MarkdownLink from "$lib/workspace/console/MarkdownLink.svelte"; import SafeMarkdown from "$lib/workspace/markdown/SafeMarkdown.svelte";
type Props = { type Props = {
text: string; text: string;
@@ -36,24 +32,15 @@
let { text, streamId = "static", class: className = "" }: Props = $props(); let { text, streamId = "static", class: className = "" }: Props = $props();
const options = {
breaks: true,
gfm: true,
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
const renderers = { const renderers = {
code: ShikiCode, code: ShikiCode,
html: buildUnsupportedHTML(),
link: MarkdownLink,
} satisfies Partial<Renderers>; } satisfies Partial<Renderers>;
</script> </script>
<div class={`rich-markdown ${className}`}> <SafeMarkdown {text} {streamId} class={`rich-markdown ${className}`} {renderers} streaming />
<SvelteMarkdown source={text} {streamId} {options} {renderers} streaming />
</div>
<style> <style>
.rich-markdown { :global(.rich-markdown) {
color: inherit; color: inherit;
line-height: var(--line-height-body); line-height: var(--line-height-body);
} }
@@ -0,0 +1,388 @@
import type {
SessionConversationTurn,
SessionHistoryPage,
SessionSnapshotEntry,
} from "$lib/generated/protocol";
import {
applyConsoleHistoryPage,
beginConsoleHistoryRequest,
type ConsoleHistoryState,
conversationTurnPreviews,
conversationTurnPreviewsFromLines,
emptyConsoleHistoryState,
historyEntries,
setConsoleHistoryTopEdge,
shouldLoadHistoryAtTop,
} from "./history";
declare const Deno: { test(name: string, fn: () => void): void };
function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(message);
}
function message(
id: string,
role: "user" | "assistant",
text: string,
): SessionSnapshotEntry {
return {
entry_id: id,
timestamp: 1,
provenance: role === "user" ? "human_input" : "model_output",
kind: "message",
role,
content: [{ kind: "text", text }],
};
}
function turn(index: number): SessionConversationTurn {
return {
turn_id: `u-${index}`,
entries: [
message(`u-${index}`, "user", `question ${index}\nignored`),
message(`a-progress-${index}`, "assistant", `working ${index}`),
message(
`a-final-${index}`,
"assistant",
`done ${index}\ndetail a\ndetail b\ndetail c`,
),
],
};
}
function page(
turns: SessionConversationTurn[],
cursor: string | null,
lineageId = "lineage-a",
compactAncestorLineageIds: string[] = [],
parentLineage?: {
lineage_id: string;
adopted_through_turn?: SessionConversationTurn | null;
},
): SessionHistoryPage {
return {
session_id: "session-a",
lineage_id: lineageId,
compact_ancestor_lineage_ids: compactAncestorLineageIds,
parent_lineage: parentLineage,
turns,
next_cursor: cursor,
has_more: cursor !== null,
};
}
Deno.test("history prepends 12 turns as 5 then 5 then 2 without duplicates", () => {
let state = emptyConsoleHistoryState();
state = beginConsoleHistoryRequest(state, null)!;
state = applyConsoleHistoryPage(
state,
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
null,
);
state = beginConsoleHistoryRequest(state, "cursor-8")!;
state = applyConsoleHistoryPage(
state,
page([3, 4, 5, 6, 7].map(turn), "cursor-3"),
"cursor-8",
);
state = beginConsoleHistoryRequest(state, "cursor-3")!;
state = applyConsoleHistoryPage(
state,
page([1, 2].map(turn), null),
"cursor-3",
);
assert(state.turns.length === 12, "all real turns must be retained");
assert(state.turns[0].turn_id === "u-1", "pages must remain chronological");
assert(
!state.hasMore && state.cursor === null,
"final short page must end history",
);
state = { ...state, requestedCursor: "__initial__", status: "loading" };
state = applyConsoleHistoryPage(
state,
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
null,
);
assert(
state.turns.length === 12,
"refresh must dedupe stable turn identities",
);
assert(
historyEntries(state).length === 36,
"entries must dedupe by stable identity",
);
});
Deno.test("refresh replaces overlapping latest turns while retaining loaded earlier turns", () => {
let state = emptyConsoleHistoryState();
state = beginConsoleHistoryRequest(state, null)!;
state = applyConsoleHistoryPage(
state,
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
null,
);
state = beginConsoleHistoryRequest(state, "cursor-8")!;
state = applyConsoleHistoryPage(
state,
page([3, 4, 5, 6, 7].map(turn), "cursor-3"),
"cursor-8",
);
const updated = turn(12);
updated.entries.push(message("a-later-12", "assistant", "actually finished"));
state = beginConsoleHistoryRequest(state, null)!;
state = applyConsoleHistoryPage(
state,
page([8, 9, 10, 11].map(turn).concat(updated), "stale-cursor-is-ignored"),
null,
);
assert(
state.turns.length === 10,
"refresh must not discard loaded earlier turns",
);
assert(
state.turns.at(-1)?.entries.at(-1)?.entry_id === "a-later-12",
"refresh must replace an overlapping turn with its latest committed entries",
);
assert(
state.cursor === "cursor-3",
"refresh must retain the oldest paging boundary",
);
});
Deno.test("same-lineage refresh without overlap replaces the paging boundary", () => {
let state: ConsoleHistoryState = {
...emptyConsoleHistoryState(),
sessionId: "session-a",
lineageId: "lineage-a",
turns: [1, 2, 3, 4].map(turn),
cursor: null,
hasMore: false,
status: "loading",
requestedCursor: "__initial__",
};
state = applyConsoleHistoryPage(
state,
page([6, 7, 8, 9, 10].map(turn), "cursor-6", "lineage-a"),
null,
);
assert(
state.turns.map((value) => value.turn_id).join(",") ===
"u-1,u-2,u-3,u-4,u-6,u-7,u-8,u-9,u-10",
"a reconnect may preserve loaded rows while still exposing the missing gap",
);
assert(
state.cursor === "cursor-6" && state.hasMore,
"a non-overlapping same-lineage page must replace an exhausted old boundary",
);
});
Deno.test("changed lineage keeps the adopted prefix and discards the replaced branch", () => {
let state = emptyConsoleHistoryState();
state = beginConsoleHistoryRequest(state, null)!;
state = applyConsoleHistoryPage(
state,
page([6, 7, 8, 9, 10].map(turn), "cursor-6"),
null,
);
state = beginConsoleHistoryRequest(state, "cursor-6")!;
state = applyConsoleHistoryPage(
state,
page([1, 2, 3, 4, 5].map(turn), null),
"cursor-6",
);
state = beginConsoleHistoryRequest(state, null)!;
state = applyConsoleHistoryPage(
state,
page([3, 4, 5, 11, 12].map(turn), "cursor-3-new", "lineage-b"),
null,
);
assert(
state.turns.map((value) => value.turn_id).join(",") ===
"u-1,u-2,u-3,u-4,u-5,u-11,u-12",
"rewind must preserve only the prefix adopted by the new lineage",
);
assert(
state.cursor === "cursor-3-new" && state.hasMore,
"a changed lineage must replace the stale paging cursor",
);
});
Deno.test("Compact lineage refresh preserves already loaded adopted history", () => {
let state: ConsoleHistoryState = {
...emptyConsoleHistoryState(),
sessionId: "session-a",
lineageId: "lineage-a",
turns: Array.from({ length: 10 }, (_, index) => turn(index + 1)),
status: "loading",
requestedCursor: "__initial__",
};
state = applyConsoleHistoryPage(
state,
page(
[11, 12, 13, 14, 15].map(turn),
"cursor-11-new",
"lineage-compact",
["lineage-a"],
),
null,
);
assert(
state.turns.map((value) => value.turn_id).join(",") ===
Array.from({ length: 15 }, (_, index) => `u-${index + 1}`).join(","),
"Compact must keep the adopted prefix without requiring newest-page overlap",
);
assert(
state.cursor === "cursor-11-new" && state.hasMore,
"Compact must replace the old-lineage cursor even when adopted turns are preserved",
);
});
Deno.test("fork lineage boundary preserves an adopted prefix without page overlap", () => {
let state: ConsoleHistoryState = {
...emptyConsoleHistoryState(),
sessionId: "session-a",
lineageId: "lineage-a",
turns: Array.from({ length: 50 }, (_, index) => turn(index + 1)),
cursor: null,
hasMore: false,
status: "loading",
requestedCursor: "__initial__",
};
state.turns[24]!.entries.push(
message("a-unadopted-25", "assistant", "stale sibling response"),
);
const adoptedBoundary = turn(25);
state = applyConsoleHistoryPage(
state,
page(
[101, 102, 103, 104, 105].map(turn),
"cursor-101",
"lineage-fork",
[],
{
lineage_id: "lineage-a",
adopted_through_turn: adoptedBoundary,
},
),
null,
);
assert(
state.turns.map((value) => value.turn_id).join(",") ===
[
...Array.from({ length: 25 }, (_, index) => `u-${index + 1}`),
...Array.from({ length: 5 }, (_, index) => `u-${index + 101}`),
].join(","),
"fork refresh must retain only the authoritative adopted prefix",
);
assert(
historyEntries(state).every((entry) => entry.entry_id !== "a-unadopted-25"),
"the canonical provider boundary must trim a sibling entry in the same visible turn",
);
assert(
state.cursor === "cursor-101" && state.hasMore,
"fork refresh must replace the old paging boundary",
);
});
Deno.test("changed lineage without a stable overlap replaces retained turns", () => {
let state: ConsoleHistoryState = {
...emptyConsoleHistoryState(),
sessionId: "session-a",
lineageId: "lineage-a",
turns: [1, 2, 3].map(turn),
cursor: "old-cursor",
hasMore: true,
status: "loading",
requestedCursor: "__initial__",
};
state = applyConsoleHistoryPage(
state,
page([11, 12].map(turn), null, "lineage-b"),
null,
);
assert(
state.turns.map((value) => value.turn_id).join(",") === "u-11,u-12",
"unrelated lineage content must not survive refresh",
);
assert(
state.cursor === null && !state.hasMore,
"new lineage boundary must be authoritative",
);
});
Deno.test("line previews include a current turn outside the retained five-turn page", () => {
const lines = [1, 2, 3, 4, 5, 6].flatMap((index) => [
{
id: `user-${index}`,
entryId: `u-${index}`,
kind: "user" as const,
title: "User",
body: `question ${index}`,
source: "event" as const,
},
{
id: `assistant-${index}`,
entryId: `a-${index}`,
kind: "assistant" as const,
title: "Assistant",
body: `done ${index}`,
source: "event" as const,
},
]);
assert(
conversationTurnPreviewsFromLines(lines).map((preview) => preview.turnId)
.join(",") ===
"u-1,u-2,u-3,u-4,u-5,u-6",
"turn navigation must include unmatched current snapshot turns in transcript order",
);
});
Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => {
const preview = conversationTurnPreviews([turn(4)])[0];
assert(preview.user === "question 4", "user preview must use its first line");
assert(
preview.assistant.join("|") === "done 4|detail a|detail b",
"preview must choose the final assistant and cap it at three lines",
);
});
Deno.test("top sentinel requests only once until it leaves the edge", () => {
let state: ConsoleHistoryState = {
...emptyConsoleHistoryState(),
status: "ready",
cursor: "older",
hasMore: true,
};
assert(
shouldLoadHistoryAtTop(state, true),
"armed top edge should request a page",
);
state = beginConsoleHistoryRequest(state, "older")!;
assert(
!shouldLoadHistoryAtTop(state, true),
"in-flight page must suppress duplicates",
);
state = {
...state,
status: "ready",
requestedCursor: null,
};
assert(
!shouldLoadHistoryAtTop(state, true),
"stationary sentinel must not drain history",
);
state = setConsoleHistoryTopEdge(state, false);
assert(
shouldLoadHistoryAtTop(state, true),
"leaving the top must rearm one request",
);
});
@@ -0,0 +1,338 @@
import type { ConsoleLine } from "./model.ts";
import type {
SessionConversationTurn,
SessionHistoryPage,
SessionSnapshotEntry,
} from "$lib/generated/protocol";
export const CONSOLE_HISTORY_PAGE_TURNS = 5;
export type ConsoleHistoryStatus = "idle" | "loading" | "ready" | "error";
export type ConsoleHistoryState = {
sessionId: string | null;
lineageId: string | null;
turns: SessionConversationTurn[];
cursor: string | null;
hasMore: boolean;
status: ConsoleHistoryStatus;
error: string | null;
/** Cursor currently in flight; prevents duplicate sentinel requests. */
requestedCursor: string | null;
/** Rearmed only after leaving the top edge or an explicit user action. */
topEdgeArmed: boolean;
};
export type ConsoleTurnPreview = {
turnId: string;
lineId: string;
user: string;
assistant: string[];
};
export function emptyConsoleHistoryState(): ConsoleHistoryState {
return {
sessionId: null,
lineageId: null,
turns: [],
cursor: null,
hasMore: false,
status: "idle",
error: null,
requestedCursor: null,
topEdgeArmed: true,
};
}
export function beginConsoleHistoryRequest(
state: ConsoleHistoryState,
cursor: string | null,
): ConsoleHistoryState | null {
if (state.status === "loading" || state.requestedCursor !== null) return null;
if (cursor !== null && (!state.hasMore || cursor !== state.cursor)) {
return null;
}
return {
...state,
status: "loading",
error: null,
requestedCursor: cursor ?? "__initial__",
topEdgeArmed: cursor === null ? state.topEdgeArmed : false,
};
}
export function applyConsoleHistoryPage(
state: ConsoleHistoryState,
page: SessionHistoryPage,
requestedCursor: string | null,
): ConsoleHistoryState {
const requestKey = requestedCursor ?? "__initial__";
if (state.requestedCursor !== requestKey) return state;
const initial = requestedCursor === null;
if (
!initial &&
(state.sessionId !== page.session_id || state.lineageId !== page.lineage_id)
) {
return failConsoleHistoryRequest(
state,
"The conversation changed while earlier history was loading. Retry from the current view.",
);
}
const incoming = page.turns.filter((turn) => turn.entries.length > 0);
const refreshingSameSession = initial &&
state.sessionId === page.session_id &&
state.turns.length > 0;
const refreshingSameLineage = refreshingSameSession &&
state.lineageId === page.lineage_id;
const refreshingParentLineage = refreshingSameSession &&
state.lineageId !== null &&
page.parent_lineage?.lineage_id === state.lineageId;
const refreshingCompactDescendant = refreshingSameSession &&
state.lineageId !== null &&
(page.compact_ancestor_lineage_ids ?? []).includes(state.lineageId);
const refreshBridgesRetained = refreshingSameLineage && incoming.some(
(turn) => state.turns.some((retained) => retained.turn_id === turn.turn_id),
);
const turns = initial
? refreshingSameLineage
? mergeRefreshedTurns(state.turns, incoming)
: refreshingParentLineage
? reconcileParentLineage(
state.turns,
incoming,
page.parent_lineage?.adopted_through_turn ?? null,
)
: refreshingCompactDescendant
? mergeRefreshedTurns(state.turns, incoming)
: refreshingSameSession
? reconcileChangedLineage(state.turns, incoming)
: dedupeTurns(incoming)
: dedupeTurns([...incoming, ...state.turns]);
return {
...state,
sessionId: page.session_id,
lineageId: page.lineage_id,
turns,
cursor: refreshBridgesRetained ? state.cursor : (page.next_cursor ?? null),
hasMore: refreshBridgesRetained ? state.hasMore : page.has_more,
status: "ready",
error: null,
requestedCursor: null,
};
}
export function failConsoleHistoryRequest(
state: ConsoleHistoryState,
message: string,
): ConsoleHistoryState {
return {
...state,
status: "error",
error: message.slice(0, 512),
requestedCursor: null,
};
}
export function setConsoleHistoryTopEdge(
state: ConsoleHistoryState,
atTop: boolean,
): ConsoleHistoryState {
if (!atTop && !state.topEdgeArmed) return { ...state, topEdgeArmed: true };
return state;
}
export function shouldLoadHistoryAtTop(
state: ConsoleHistoryState,
atTop: boolean,
): boolean {
return atTop && state.topEdgeArmed && state.hasMore &&
state.status !== "loading" && state.requestedCursor === null;
}
export function historyEntries(
state: ConsoleHistoryState,
): SessionSnapshotEntry[] {
return state.turns.flatMap((turn) => turn.entries);
}
export function conversationTurnPreviews(
turns: readonly SessionConversationTurn[],
): ConsoleTurnPreview[] {
return turns.map((turn) => {
const userEntry = turn.entries.find(isUserEntry);
const assistants = turn.entries
.filter(isAssistantEntry)
.map(entryText)
.filter((text) => text.trim().length > 0);
return {
turnId: turn.turn_id,
lineId: userEntry?.entry_id ?? turn.turn_id,
user: firstNonEmptyLine(userEntry ? entryText(userEntry) : ""),
assistant: previewAssistant(assistants.at(-1) ?? ""),
};
});
}
export function conversationTurnPreviewsFromLines(
lines: readonly ConsoleLine[],
): ConsoleTurnPreview[] {
const previews: ConsoleTurnPreview[] = [];
let current: {
turnId: string;
lineId: string;
user: string;
assistants: string[];
} | null = null;
for (const line of lines) {
if (line.kind === "user") {
if (current) {
previews.push({
turnId: current.turnId,
lineId: current.lineId,
user: current.user,
assistant: previewAssistant(current.assistants.at(-1) ?? ""),
});
}
const stableId = line.entryId ?? line.id;
current = {
turnId: stableId,
lineId: stableId,
user: firstNonEmptyLine(line.body),
assistants: [],
};
} else if (
(line.kind === "assistant" ||
(line.kind === "in_flight" && line.title === "in-flight assistant text")) &&
current && line.body.trim().length > 0
) {
current.assistants.push(line.body);
}
}
if (current) {
previews.push({
turnId: current.turnId,
lineId: current.lineId,
user: current.user,
assistant: previewAssistant(current.assistants.at(-1) ?? ""),
});
}
return previews;
}
function previewAssistant(value: string): string[] {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 3);
}
function reconcileParentLineage(
retained: readonly SessionConversationTurn[],
refreshed: readonly SessionConversationTurn[],
adoptedThroughTurn: SessionConversationTurn | null,
): SessionConversationTurn[] {
if (adoptedThroughTurn === null) return dedupeTurns(refreshed);
const boundary = retained.findIndex((turn) =>
turn.turn_id === adoptedThroughTurn.turn_id
);
if (boundary < 0) return dedupeTurns(refreshed);
const refreshedIncludesBoundary = refreshed.some((turn) =>
turn.turn_id === adoptedThroughTurn.turn_id
);
return dedupeTurns([
...retained.slice(0, boundary),
...(refreshedIncludesBoundary ? [] : [adoptedThroughTurn]),
...refreshed,
]);
}
function reconcileChangedLineage(
retained: readonly SessionConversationTurn[],
refreshed: readonly SessionConversationTurn[],
): SessionConversationTurn[] {
const retainedPositions = new Map(
retained.map((turn, index) => [turn.turn_id, index] as const),
);
const firstShared = refreshed.find((turn) =>
retainedPositions.has(turn.turn_id)
);
if (!firstShared) return dedupeTurns(refreshed);
const retainedPrefix = retained.slice(
0,
retainedPositions.get(firstShared.turn_id) ?? 0,
);
return dedupeTurns([...retainedPrefix, ...refreshed]);
}
function mergeRefreshedTurns(
retained: readonly SessionConversationTurn[],
refreshed: readonly SessionConversationTurn[],
): SessionConversationTurn[] {
const refreshedIds = new Set(refreshed.map((turn) => turn.turn_id));
return dedupeTurns([
...retained.filter((turn) => !refreshedIds.has(turn.turn_id)),
...refreshed,
]);
}
function dedupeTurns(
turns: readonly SessionConversationTurn[],
): SessionConversationTurn[] {
const seenTurns = new Set<string>();
const seenEntries = new Set<string>();
const result: SessionConversationTurn[] = [];
for (const turn of turns) {
if (seenTurns.has(turn.turn_id)) continue;
const entries = turn.entries.filter((entry) => {
if (seenEntries.has(entry.entry_id)) return false;
seenEntries.add(entry.entry_id);
return true;
});
if (entries.length === 0) continue;
seenTurns.add(turn.turn_id);
result.push({ ...turn, entries });
}
return result;
}
function isUserEntry(entry: SessionSnapshotEntry): boolean {
return entry.kind === "user_input" ||
(entry.kind === "message" && entry.role === "user");
}
function isAssistantEntry(entry: SessionSnapshotEntry): boolean {
return entry.kind === "message" && entry.role === "assistant";
}
function entryText(entry: SessionSnapshotEntry): string {
switch (entry.kind) {
case "user_input":
return entry.segments.map((segment) => {
if (segment.kind === "text" || segment.kind === "paste") {
return segment.content;
}
if (segment.kind === "file_ref") return `@file ${segment.path}`;
if (segment.kind === "uploaded_file") {
return `[Attachment: ${segment.file.file_name}]`;
}
if (segment.kind === "paste_artifact") {
return `[Large paste ${segment.artifact.artifact_id}]`;
}
return "";
}).filter(Boolean).join("\n");
case "message":
return entry.content.map((part) =>
part.kind === "text" ? part.text : part.refusal
).join("\n");
default:
return "";
}
}
function firstNonEmptyLine(value: string): string {
return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "—";
}
@@ -9,6 +9,7 @@ import {
consoleWorkerViews, consoleWorkerViews,
createConsoleProjector, createConsoleProjector,
isConsoleProjectionEvent, isConsoleProjectionEvent,
mergeCommittedHistoryLines,
projectConsole, projectConsole,
projectConsoleLines, projectConsoleLines,
projectOverviewLines, projectOverviewLines,
@@ -2950,3 +2951,203 @@ Deno.test("new invoke resets stats before the next RunEnd", () => {
assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0"); assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0");
}); });
Deno.test("committed assistant identity reconciles the live block without text matching", () => {
const lines = projectConsole([
{
eventId: "user-live",
event: {
event: "user_message",
data: {
entry_id: "user-entry",
segments: [{ kind: "text", content: "question" }],
},
},
},
{
eventId: "assistant-live",
event: { event: "text_done", data: { text: "final answer" } },
},
{
eventId: "assistant-commit",
event: {
event: "session_entry_committed",
data: {
entry: {
entry_id: "assistant-entry",
timestamp: 7,
provenance: "model_output",
kind: "message",
role: "assistant",
content: [{ kind: "text", text: "final answer" }],
},
},
},
},
]).lines;
assertEquals(lines.filter((line) => line.kind === "assistant").length, 1);
assertEquals(
lines.find((line) => line.kind === "assistant")?.entryId,
"assistant-entry",
);
assertEquals(
lines.find((line) => line.kind === "user")?.entryId,
"user-entry",
);
});
Deno.test("committed tool identities reconcile one live call block", () => {
const lines = projectConsole([
{
eventId: "tool-live",
event: {
event: "tool_call_done",
data: { id: "call-1", name: "Read", arguments: '{"file_path":"a"}' },
},
},
{
eventId: "tool-call-commit",
event: {
event: "session_entry_committed",
data: {
entry: {
entry_id: "tool-call-entry",
timestamp: 8,
provenance: "model_output",
kind: "tool_call",
call_id: "call-1",
name: "Read",
arguments: '{"file_path":"a"}',
},
},
},
},
{
eventId: "tool-result-live",
event: {
event: "tool_result",
data: {
id: "call-1",
summary: "read",
output: "contents",
is_error: false,
},
},
},
{
eventId: "tool-result-commit",
event: {
event: "session_entry_committed",
data: {
entry: {
entry_id: "tool-result-entry",
timestamp: 9,
provenance: "tool_output",
kind: "tool_result",
call_id: "call-1",
summary: "read",
content: "contents",
is_error: false,
},
},
},
},
]).lines;
assertEquals(
lines.filter((line) => line.toolCall?.id === "call-1").length,
1,
);
assertEquals(lines[0].entryId, "tool-result-entry");
assertEquals(lines[0].toolCall?.state, "done");
});
Deno.test("committed history preserves unmatched current rows in stable entry order", () => {
const history = [2, 3, 4, 5, 6].flatMap((turn) => {
const user = consoleLine(`history-user-${turn}`, "user");
user.entryId = `user-${turn}`;
const assistant = consoleLine(`history-assistant-${turn}`, "assistant");
assistant.entryId = `assistant-${turn}`;
return [user, assistant];
});
const current = [1, 2, 3, 4, 5, 6].flatMap((turn) => {
const user = consoleLine(`current-user-${turn}`, "user");
user.entryId = `user-${turn}`;
const assistant = consoleLine(`current-assistant-${turn}`, "assistant");
assistant.entryId = `assistant-${turn}`;
return [user, assistant];
});
const merged = mergeCommittedHistoryLines(history, current);
assertEquals(
merged.map((line) => line.entryId),
[1, 2, 3, 4, 5, 6].flatMap((turn) => [
`user-${turn}`,
`assistant-${turn}`,
]),
);
assert(
merged.every((line) => line.id.startsWith("current-")),
"the ordered current snapshot must remain authoritative for overlapping entries",
);
});
Deno.test("committed history places omitted retained rows between snapshot anchors", () => {
const makeLine = (prefix: string, turn: number) => {
const line = consoleLine(`${prefix}-${turn}`, "user");
line.entryId = `user-${turn}`;
return line;
};
const history = [2, 3, 4, 5, 6].map((turn) => makeLine("history", turn));
const current = [1, 2, 6].map((turn) => makeLine("current", turn));
assertEquals(
mergeCommittedHistoryLines(history, current).map((line) => line.entryId),
[1, 2, 3, 4, 5, 6].map((turn) => `user-${turn}`),
);
});
Deno.test("committed history places current context before an interior first anchor", () => {
const makeLine = (prefix: string, turn: number) => {
const line = consoleLine(`${prefix}-${turn}`, "user");
line.entryId = `user-${turn}`;
return line;
};
const history = [6, 7, 8, 9, 10].map((turn) => makeLine("history", turn));
const current = [1, 8, 10].map((turn) => makeLine("current", turn));
assertEquals(
mergeCommittedHistoryLines(history, current).map((line) => line.entryId),
[1, 6, 7, 8, 9, 10].map((turn) => `user-${turn}`),
);
});
Deno.test("committed history places a retained suffix before newer current-only rows", () => {
const makeLine = (prefix: string, turn: number) => {
const line = consoleLine(`${prefix}-${turn}`, "user");
line.entryId = `user-${turn}`;
return line;
};
const history = [2, 3, 4, 5, 6].map((turn) => makeLine("history", turn));
const current = [1, 2, 7].map((turn) => makeLine("current", turn));
assertEquals(
mergeCommittedHistoryLines(history, current).map((line) => line.entryId),
[1, 2, 3, 4, 5, 6, 7].map((turn) => `user-${turn}`),
);
});
Deno.test("committed history inserts current live rows after their last stable anchor", () => {
const historyUser = consoleLine("history-user", "user");
historyUser.entryId = "user-1";
const currentUser = consoleLine("current-user", "user");
currentUser.entryId = "user-1";
const live = consoleLine("live-assistant", "in_flight");
assertEquals(
mergeCommittedHistoryLines([historyUser], [currentUser, live]).map((line) =>
line.id
),
["current-user", "live-assistant"],
);
});
@@ -10,6 +10,7 @@ import type {
InternalWorkerRef, InternalWorkerRef,
InternalWorkerSnapshot, InternalWorkerSnapshot,
Segment, Segment,
SessionSnapshotEntry,
WorkerState, WorkerState,
WorkerStateSnapshot, WorkerStateSnapshot,
WorkerStatus, WorkerStatus,
@@ -90,6 +91,8 @@ export type ConsoleLine = {
compaction?: ConsoleCompaction; compaction?: ConsoleCompaction;
diff?: ConsoleDiffLine[]; diff?: ConsoleDiffLine[];
eventId?: string | null; eventId?: string | null;
/** Stable durable identity when this line originates from a committed entry. */
entryId?: string;
source: "event"; source: "event";
streaming?: boolean; streaming?: boolean;
error?: boolean; error?: boolean;
@@ -246,6 +249,86 @@ export function createConsoleProjector() {
}; };
} }
export function mergeCommittedHistoryLines(
history: ConsoleLine[],
current: ConsoleLine[],
): ConsoleLine[] {
const currentEntryIds = new Set(
current.map((line) => line.entryId).filter((id): id is string =>
Boolean(id)
),
);
const historyPositions = new Map<string, number>();
history.forEach((line, index) => {
if (line.entryId && !historyPositions.has(line.entryId)) {
historyPositions.set(line.entryId, index);
}
});
// Use stable entries that occur in the same order in both projections as
// anchors. History-only rows are inserted in their retained interval, while
// current-only rows retain the snapshot's authoritative order. This handles
// Compact snapshots that preserve only a non-contiguous subset of context.
const anchors: Array<{ history: number; current: number }> = [];
let lastHistoryIndex = -1;
current.forEach((line, currentIndex) => {
const historyIndex = line.entryId
? historyPositions.get(line.entryId)
: undefined;
if (historyIndex !== undefined && historyIndex > lastHistoryIndex) {
anchors.push({ history: historyIndex, current: currentIndex });
lastHistoryIndex = historyIndex;
}
});
if (anchors.length === 0) {
return [
...history.filter((line) =>
!line.entryId || !currentEntryIds.has(line.entryId)
),
...current,
];
}
const merged: ConsoleLine[] = [];
let historyCursor = 0;
let currentCursor = 0;
const appendHistoryInterval = (end: number) => {
for (; historyCursor < end; historyCursor += 1) {
const line = history[historyCursor]!;
if (!line.entryId || !currentEntryIds.has(line.entryId)) {
merged.push(line);
}
}
};
for (const [index, anchor] of anchors.entries()) {
if (index === 0) {
// Current rows before the first shared entry can be older retained
// context that the newest history page starts after.
merged.push(...current.slice(currentCursor, anchor.current));
appendHistoryInterval(anchor.history);
} else {
appendHistoryInterval(anchor.history);
merged.push(...current.slice(currentCursor, anchor.current));
}
merged.push(current[anchor.current]!);
historyCursor = anchor.history + 1;
currentCursor = anchor.current + 1;
}
appendHistoryInterval(history.length);
merged.push(...current.slice(currentCursor));
return merged;
}
export function projectSessionHistoryEntries(
entries: SessionSnapshotEntry[],
cwd: string | null,
): ConsoleLine[] {
return projectVisibleConsole(
snapshotProjectionFromSession("history", { entries }, cwd),
).lines;
}
function projectVisibleConsole( function projectVisibleConsole(
projection: ConsoleProjection, projection: ConsoleProjection,
): ConsoleProjection { ): ConsoleProjection {
@@ -813,14 +896,19 @@ export function applyProtocolEvent(
switch (event.event) { switch (event.event) {
case "user_message": case "user_message":
next.lines.push( next.lines.push({
line( ...line(
envelope.eventId, envelope.eventId,
"user", "user",
"User", "User",
segmentsToText(event.data.segments), segmentsToText(event.data.segments),
), ),
); entryId: event.data.entry_id ?? undefined,
});
break;
case "session_entry_committed":
reconcileCommittedSessionEntry(next, event.data.entry);
applySessionEntry(next, envelope.eventId, event.data.entry);
break; break;
case "system_item": case "system_item":
next.lines.push(systemItemLine(envelope.eventId, event.data.item)); next.lines.push(systemItemLine(envelope.eventId, event.data.item));
@@ -1507,6 +1595,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done", toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
detail: calls.map(readDetail).join("\n\n"), detail: calls.map(readDetail).join("\n\n"),
eventId: group.at(-1)?.eventId, eventId: group.at(-1)?.eventId,
entryId: group.at(-1)?.entryId,
source: "event", source: "event",
streaming: inProgress, streaming: inProgress,
error: hasError, error: hasError,
@@ -1971,6 +2060,22 @@ function snapshotProjectionFromSession(
return projection; return projection;
} }
function reconcileCommittedSessionEntry(
projection: ConsoleProjection,
entry: SessionSnapshotEntry,
): void {
if (entry.kind !== "message" || entry.role !== "assistant") return;
for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
const line = projection.lines[index];
if (line.entryId) continue;
if (line.kind === "assistant" || line.kind === "in_flight") {
projection.lines.splice(index, 1);
return;
}
if (line.kind === "user") return;
}
}
function applySessionEntry( function applySessionEntry(
projection: ConsoleProjection, projection: ConsoleProjection,
fallbackEventId: string, fallbackEventId: string,
@@ -1978,6 +2083,7 @@ function applySessionEntry(
): void { ): void {
if (!isRecord(value)) return; if (!isRecord(value)) return;
const eventId = stringField(value, "entry_id") ?? fallbackEventId; const eventId = stringField(value, "entry_id") ?? fallbackEventId;
const lineCountBefore = projection.lines.length;
switch (stringField(value, "kind")) { switch (stringField(value, "kind")) {
case "user_input": case "user_input":
projection.lines.push( projection.lines.push(
@@ -2035,6 +2141,18 @@ function applySessionEntry(
default: default:
break; break;
} }
const entryId = stringField(value, "entry_id");
if (entryId) {
for (
let index = Math.max(0, lineCountBefore - 1);
index < projection.lines.length;
index += 1
) {
if (projection.lines[index].eventId === eventId) {
projection.lines[index].entryId = entryId;
}
}
}
} }
function applyLoggedUserInput( function applyLoggedUserInput(
@@ -34,3 +34,14 @@ export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] {
} }
return turns; return turns;
} }
import type { SessionConversationTurn } from "$lib/generated/protocol";
import { type ConsoleTurnPreview, conversationTurnPreviews } from "./history";
export type TurnNavigationItem = ConsoleTurnPreview;
export function buildTurnNavigationItems(
turns: readonly SessionConversationTurn[],
): TurnNavigationItem[] {
return conversationTurnPreviews(turns);
}
@@ -496,11 +496,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
assert( assert(
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") && memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
memoryDocumentLoad.includes('"/memory"') && memoryDocumentLoad.includes('"/memory"') &&
memoryDocumentPage.includes("Memory Document") && memoryDocumentPage.includes("DocumentMarkdown from") &&
memoryDocumentPage.includes("This view is read-only") && memoryDocumentPage.includes("Read-only") &&
memoryDocumentPage.includes("data.memory.data.body_md") && memoryDocumentPage.includes("data.memory.data.body_md") &&
memoryDocumentPage.includes("data.memory.data.updated_at"), memoryDocumentPage.includes("data.memory.data.updated_at") &&
"Memory Document page should read the scoped API and expose the durable document without mutation controls", memoryDocumentPage.includes('role="alert"') &&
!memoryDocumentPage.includes('<pre class="memory-document-body"'),
"Memory Document page should read the scoped API and expose safe Markdown states without mutation controls",
); );
assert( assert(
memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") && memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") &&
@@ -790,19 +792,19 @@ Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves sc
const navigation = await Deno.readTextFile(new URL("./ConsoleTurnNavigation.svelte", import.meta.url)); const navigation = await Deno.readTextFile(new URL("./ConsoleTurnNavigation.svelte", import.meta.url));
assert( assert(
!/timeline/i.test(consolePage) && !/timeline/i.test(consolePage) &&
consolePage.includes("const turns = $derived(consoleTurns(lines))") && consolePage.includes("conversationTurnPreviewsFromLines(lines)") &&
consolePage.includes("<ConsoleTurnNavigation {turns} onSelect={selectConsoleTurn}") && consolePage.includes("items={turnNavigationItems}") &&
consolePage.includes("CSS.escape(id)") && consolePage.includes("CSS.escape(id)") &&
consolePage.includes("scrollbar-width: none") && consolePage.includes("scrollbar-width: none") &&
consolePage.includes("::-webkit-scrollbar"), consolePage.includes("::-webkit-scrollbar"),
"Console should replace the scrollbar with conversation turn navigation, not the former timeline", "Console should replace the scrollbar with conversation turn navigation, not the former timeline",
); );
assert( assert(
navigation.includes("{#each turns as turn, index (turn.id)}") && navigation.includes("{#each items as turn, index (turn.turnId)}") &&
navigation.includes('class="turn-bar"') && navigation.includes('class="turn-bar"') &&
navigation.includes("onmouseenter=") && navigation.includes("onmouseenter=") &&
navigation.includes("onfocus=") && navigation.includes("onfocus=") &&
navigation.includes("onclick={() => onSelect(turn.id)}") && navigation.includes("onclick={() => onTurnClick(turn)}") &&
navigation.includes('role="tooltip"') && navigation.includes('role="tooltip"') &&
navigation.includes("aria-describedby=") && navigation.includes("aria-describedby=") &&
navigation.includes("event.key === 'Escape'") && navigation.includes("event.key === 'Escape'") &&
@@ -0,0 +1,17 @@
<script lang="ts">
let {
lang = "",
text,
}: {
lang?: string;
text: string;
codeBlockStyle?: "indented";
} = $props();
const label = $derived(lang ? `${lang} code block` : "Code block");
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
<div class="markdown-code-region" role="region" aria-label={label} tabindex="0">
<pre><code>{text}</code></pre>
</div>
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { cleanup, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test } from "vitest";
import DocumentMarkdown from "./DocumentMarkdown.svelte";
afterEach(cleanup);
test("renders document Markdown semantics through the shared safe policy", async () => {
const source = [
"# Memory heading",
"",
"##### Deep heading",
"",
"###### Deepest heading",
"",
"A paragraph with **strong context** and `inline-code`.",
"",
"- first item",
"- second item",
"",
"> durable quote",
"",
"| Decision | Owner |",
"| --- | --- |",
"| Keep the document safe | Workspace |",
"",
"```rust",
'fn main() { println!("safe"); }',
"```",
"",
"[safe](https://example.com) [script](javascript:alert(1)) [relative](/internal)",
"",
'<img src="x" onerror="alert(1)"><script>alert(1)</script>',
].join("\n");
const { container } = render(DocumentMarkdown, { text: source });
await waitFor(() => {
expect(container.querySelector("h1")?.textContent).toBe("Memory heading");
expect(container.querySelector("table")).not.toBeNull();
});
expect(container.querySelector("h5")?.textContent).toBe("Deep heading");
expect(container.querySelector("h6")?.textContent).toBe("Deepest heading");
expect(container.querySelector("strong")?.textContent).toBe("strong context");
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.querySelector("blockquote")?.textContent).toContain(
"durable quote",
);
expect(container.querySelector('a[href="https://example.com"]')).not
.toBeNull();
expect(container.querySelectorAll("a")).toHaveLength(1);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("script")).toBeNull();
const tableRegion = container.querySelector<HTMLElement>(
'[role="region"][aria-label="Markdown table"]',
);
const codeRegion = container.querySelector<HTMLElement>(
'[role="region"][aria-label="rust code block"]',
);
expect(tableRegion?.tabIndex).toBe(0);
expect(codeRegion?.tabIndex).toBe(0);
expect(codeRegion?.querySelector("pre > code")?.textContent).toContain(
"println!",
);
});
test("wraps long prose and identifiers without turning them into document-level controls", async () => {
const longIdentifier = "memory-identifier-".repeat(40);
const { container } = render(DocumentMarkdown, {
text:
`Long value: ${longIdentifier}\n\n<details open><summary>unsafe HTML</summary></details>`,
});
await waitFor(() => expect(container.textContent).toContain(longIdentifier));
expect(container.querySelector("details")).toBeNull();
expect(container.querySelector("button")).toBeNull();
});
@@ -0,0 +1,148 @@
<script lang="ts">
import type { Renderers } from "@humanspeak/svelte-markdown";
import DocumentCodeBlock from "./DocumentCodeBlock.svelte";
import MarkdownTable from "./MarkdownTable.svelte";
import SafeMarkdown from "./SafeMarkdown.svelte";
let { text }: { text: string } = $props();
const renderers = {
code: DocumentCodeBlock,
table: MarkdownTable,
} satisfies Partial<Renderers>;
</script>
<SafeMarkdown {text} class="document-markdown" {renderers} />
<style>
:global(.document-markdown) {
min-width: 0;
max-width: 100%;
color: var(--text);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
overflow-wrap: anywhere;
}
:global(.document-markdown > :first-child) {
margin-top: 0;
}
:global(.document-markdown > :last-child) {
margin-bottom: 0;
}
:global(.document-markdown h1) {
margin: var(--space-6) 0 var(--space-4);
color: var(--text-strong);
font-size: var(--font-size-title);
line-height: var(--line-height-title);
}
:global(.document-markdown h2),
:global(.document-markdown h3),
:global(.document-markdown h4),
:global(.document-markdown h5),
:global(.document-markdown h6) {
margin: var(--space-6) 0 var(--space-2);
color: var(--text-strong);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
:global(.document-markdown h2) {
font-weight: 700;
}
:global(.document-markdown h3),
:global(.document-markdown h4),
:global(.document-markdown h5),
:global(.document-markdown h6) {
font-weight: 600;
}
:global(.document-markdown p),
:global(.document-markdown ul),
:global(.document-markdown ol),
:global(.document-markdown blockquote),
:global(.document-markdown .markdown-code-region),
:global(.document-markdown .markdown-table-region) {
margin: 0 0 var(--space-4);
}
:global(.document-markdown ul),
:global(.document-markdown ol) {
padding-left: var(--space-5);
}
:global(.document-markdown li + li) {
margin-top: var(--space-1);
}
:global(.document-markdown blockquote) {
margin-left: 0;
padding-left: var(--space-4);
border-left: 2px solid var(--line-strong);
color: var(--text-muted);
}
:global(.document-markdown a) {
color: var(--accent);
text-underline-offset: 0.18em;
}
:global(.document-markdown :not(pre) > code) {
padding: 0 var(--space-1);
background: var(--bg-subtle);
color: var(--code);
overflow-wrap: anywhere;
}
:global(.document-markdown .markdown-code-region),
:global(.document-markdown .markdown-table-region) {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
:global(.document-markdown .markdown-code-region) {
background: var(--bg-raised);
color: var(--code);
}
:global(.document-markdown .markdown-code-region pre) {
width: max-content;
min-width: 100%;
margin: 0;
padding: var(--space-4);
background: transparent;
color: inherit;
white-space: pre;
}
:global(.document-markdown .markdown-table-region table) {
width: max-content;
min-width: 100%;
border-collapse: collapse;
}
:global(.document-markdown .markdown-table-region th),
:global(.document-markdown .markdown-table-region td) {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
white-space: nowrap;
}
:global(.document-markdown .markdown-table-region th) {
color: var(--text-strong);
font-weight: 600;
}
:global(.document-markdown hr) {
margin: var(--space-6) 0;
border: 0;
border-top: 1px solid var(--line);
}
</style>
@@ -0,0 +1,10 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { children }: { children?: Snippet } = $props();
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
<div class="markdown-table-region" role="region" aria-label="Markdown table" tabindex="0">
<table>{@render children?.()}</table>
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import SvelteMarkdown, {
buildUnsupportedHTML,
type Renderers,
type SvelteMarkdownProps,
} from "@humanspeak/svelte-markdown";
import MarkdownLink from "$lib/workspace/markdown/MarkdownLink.svelte";
type Props = {
text: string;
streamId?: string | number;
class?: string;
streaming?: boolean;
renderers?: Partial<Renderers>;
};
let {
text,
streamId = "static",
class: className = "",
streaming = false,
renderers = {},
}: Props = $props();
const options = {
breaks: true,
gfm: true,
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
const safeRenderers = $derived({
...renderers,
html: buildUnsupportedHTML(),
link: MarkdownLink,
} satisfies Partial<Renderers>);
</script>
<div class={className}>
<SvelteMarkdown source={text} {streamId} {options} renderers={safeRenderers} {streaming} />
</div>
@@ -1,6 +1,6 @@
import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api"; import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api";
/** Render independent Workdir authority categories in a stable, non-conflating order. */ /** Render an effective attachment/session capability intersection without widening it. */
export function formatWorkdirPermissions( export function formatWorkdirPermissions(
permissions: ExternalWorkdirPermissions, permissions: ExternalWorkdirPermissions,
): string { ): string {
@@ -10,3 +10,21 @@ export function formatWorkdirPermissions(
if (permissions.command) categories.push("COMMAND"); if (permissions.command) categories.push("COMMAND");
return categories.length > 0 ? categories.join(" · ") : "NONE"; return categories.length > 0 ? categories.join(" · ") : "NONE";
} }
/** Render one validated External grant level with its inherited authority. */
export function formatExternalGrantPermissionLevel(
permissions: ExternalWorkdirPermissions,
): string {
if (permissions.read && permissions.write && permissions.command) {
return "COMMAND (includes WRITE + READ)";
}
if (permissions.read && permissions.write && !permissions.command) {
return "WRITE (includes READ)";
}
if (permissions.read && !permissions.write && !permissions.command) {
return "READ";
}
throw new Error(
"External Workdir grant has a non-hierarchical permission set",
);
}
@@ -1,123 +1,165 @@
<script lang="ts"> <script lang="ts">
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate } from '$lib/workspace/api/http'; import { formatDate } from '$lib/workspace/api/http';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
const lineCount = $derived(data.memory.data?.body_md.split('\n').length ?? 0);
</script> </script>
<svelte:head> <svelte:head>
<title>Memory Document · Yoi Workspace</title> <title>Memory · Yoi Workspace</title>
<meta name="description" content="Workspace Memory Document" /> <meta name="description" content="Workspace Memory document" />
</svelte:head> </svelte:head>
<section class="card memory-document-card"> <section class="memory-document-page" aria-label="Memory document">
<div class="detail-heading">
<div>
<p class="eyebrow">Workspace memory</p>
<h2>Memory Document</h2>
</div>
{#if data.memory.data}
<span>{data.memory.data.bytes} bytes</span>
{/if}
</div>
<p class="section-note">
Durable workspace Memory as a single Markdown document. This view is read-only.
</p>
{#if data.memory.data} {#if data.memory.data}
<div class="memory-document-summary" aria-label="Memory document summary"> <div class="memory-document-meta">
<div> <p>
<span>Updated</span> <span>Read-only</span>
<strong>{formatDate(data.memory.data.updated_at)}</strong> <span aria-hidden="true">·</span>
</div> <span>Updated <time datetime={data.memory.data.updated_at}>{formatDate(data.memory.data.updated_at)}</time></span>
<div> </p>
<span>Created</span> <details>
<strong>{formatDate(data.memory.data.created_at)}</strong> <summary>Document details</summary>
</div> <dl>
<div> <div>
<span>Lines</span> <dt>Created</dt>
<strong>{lineCount}</strong> <dd><time datetime={data.memory.data.created_at}>{formatDate(data.memory.data.created_at)}</time></dd>
</div> </div>
<div> <div>
<span>Source</span> <dt>Size</dt>
<strong>{data.memory.data.record_source}</strong> <dd>{data.memory.data.bytes} bytes</dd>
</div> </div>
<div>
<dt>Source</dt>
<dd><code>{data.memory.data.record_source}</code></dd>
</div>
</dl>
</details>
</div> </div>
{#if data.memory.data.body_md.trim().length === 0} {#if data.memory.data.body_md.trim().length === 0}
<p>No Memory document content is present.</p> <div class="memory-document-state" role="status">
<strong>Memory document is empty.</strong>
<p>Durable Workspace context will appear here when it is available.</p>
</div>
{:else} {:else}
<pre class="memory-document-body">{data.memory.data.body_md}</pre> <article class="memory-document-content" aria-label="Memory document content">
<DocumentMarkdown text={data.memory.data.body_md} />
</article>
{/if} {/if}
{:else if data.memory.error} {:else if data.memory.error}
<p class="error">{data.memory.error}</p> <div class="memory-document-state memory-document-error">
<p role="alert"><strong>Memory document unavailable.</strong> {data.memory.error}</p>
</div>
{:else} {:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/memory</code>…</p> <div class="memory-document-state" role="status">
<p>Memory document data is unavailable.</p>
</div>
{/if} {/if}
</section> </section>
<style> <style>
.memory-document-card { .memory-document-page {
overflow: hidden;
}
.memory-document-summary {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); flex: 0 0 auto;
gap: 0.75rem; gap: var(--space-5);
margin: 1rem 0; width: 100%;
min-width: 0;
max-width: 78rem;
margin-inline: auto;
} }
.memory-document-summary div { .memory-document-meta {
border: 1px solid var(--line); display: flex;
border-radius: 0.75rem; align-items: baseline;
background: var(--bg-raised); justify-content: space-between;
padding: 0.8rem; gap: var(--space-4);
}
.memory-document-summary span {
color: var(--text-muted); color: var(--text-muted);
display: block;
font-size: var(--font-size-compact); font-size: var(--font-size-compact);
font-weight: 600; line-height: var(--line-height-compact);
letter-spacing: 0.04em;
text-transform: uppercase;
} }
.memory-document-summary strong { .memory-document-meta > p {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0;
}
.memory-document-meta details {
flex: 0 0 auto;
}
.memory-document-meta summary {
color: var(--text-muted);
cursor: pointer;
}
.memory-document-meta dl {
display: grid;
gap: var(--space-2);
min-width: min(24rem, calc(100vw - (2 * var(--space-4))));
margin: var(--space-3) 0 0;
padding: var(--space-3) 0 0;
border-top: 1px solid var(--line);
}
.memory-document-meta dl > div {
display: grid;
grid-template-columns: 5rem minmax(0, 1fr);
gap: var(--space-3);
}
.memory-document-meta dt {
color: var(--text-faint);
}
.memory-document-meta dd {
min-width: 0;
margin: 0;
color: var(--text); color: var(--text);
display: block;
font-size: var(--font-size-compact);
margin-top: 0.25rem;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.memory-document-body { .memory-document-content {
background: var(--bg-raised); min-width: 0;
border: 1px solid var(--line);
border-radius: 0.9rem;
color: var(--text);
font-family: var(--font-mono);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
margin: 1rem 0 0;
overflow: auto;
padding: 1rem;
white-space: pre-wrap;
} }
@media (max-width: 900px) { .memory-document-state {
.memory-document-summary { padding-block: var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr)); color: var(--text-muted);
}
.memory-document-state strong {
color: var(--text-strong);
}
.memory-document-state p {
margin: var(--space-1) 0 0;
}
.memory-document-state > p:first-child {
margin-top: 0;
}
.memory-document-error,
.memory-document-error strong {
color: var(--danger);
}
@media (max-width: 600px) {
.memory-document-meta {
display: grid;
gap: var(--space-2);
} }
}
@media (max-width: 640px) { .memory-document-meta details {
.memory-document-summary { width: 100%;
grid-template-columns: 1fr; }
.memory-document-meta dl {
min-width: 0;
} }
} }
</style> </style>
@@ -0,0 +1,76 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test } from "vitest";
import MemoryPage from "./+page.svelte";
import type { PageProps } from "./$types";
function pageProps(memory: {
data: {
body_md: string;
created_at: string;
updated_at: string;
bytes: number;
record_source: string;
} | null;
error: string | null;
}): PageProps {
return {
data: { workspaceId: "workspace-1", memory },
params: { workspaceId: "workspace-1" },
} as unknown as PageProps;
}
afterEach(cleanup);
test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => {
const { container } = render(
MemoryPage,
pageProps({
data: {
body_md: "# Durable context\n\nRead the **current** document.",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
bytes: 48,
record_source: "sqlite_workspace_authority.memory_document",
},
error: null,
}),
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull();
});
expect(screen.getByText("Read-only")).not.toBeNull();
expect(screen.getByText("Document details")).not.toBeNull();
expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false);
expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull();
expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull();
});
test("distinguishes an empty document from request failure and unavailable data", async () => {
const view = render(
MemoryPage,
pageProps({
data: {
body_md: " \n",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
bytes: 3,
record_source: "fixture",
},
error: null,
}),
);
expect(screen.getByRole("status").textContent).toContain("Memory document is empty.");
expect(screen.queryByRole("alert")).toBeNull();
await view.rerender(pageProps({ data: null, error: "Memory API request failed" }));
expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable.");
expect(screen.getByRole("alert").textContent).toContain("Memory API request failed");
expect(screen.queryByText("Memory document is empty.")).toBeNull();
await view.rerender(pageProps({ data: null, error: null }));
expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable.");
expect(screen.queryByRole("alert")).toBeNull();
});
@@ -3,7 +3,6 @@
import { tick, untrack, type SvelteComponent } from "svelte"; import { tick, untrack, type SvelteComponent } from "svelte";
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte"; import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleTurnNavigation from "$lib/workspace/console/ConsoleTurnNavigation.svelte"; import ConsoleTurnNavigation from "$lib/workspace/console/ConsoleTurnNavigation.svelte";
import { consoleTurns } from "$lib/workspace/console/turn-navigation";
import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte"; import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte";
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte"; import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
import { namePastedImage } from "$lib/workspace/console/composer-paste"; import { namePastedImage } from "$lib/workspace/console/composer-paste";
@@ -30,7 +29,9 @@
consoleWorkerViews, consoleWorkerViews,
createConsoleProjector, createConsoleProjector,
isConsoleProjectionEvent, isConsoleProjectionEvent,
mergeCommittedHistoryLines,
projectConsoleLines, projectConsoleLines,
projectSessionHistoryEntries,
resolveConsoleViewScrollTop, resolveConsoleViewScrollTop,
resolveConsoleWorkerView, resolveConsoleWorkerView,
type ConsoleEventInput, type ConsoleEventInput,
@@ -44,6 +45,7 @@
PendingSubmissionsSnapshot, PendingSubmissionsSnapshot,
RewindTarget, RewindTarget,
Segment, Segment,
SessionHistoryPage,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import { import {
MAX_FILES_PER_SUBMISSION, MAX_FILES_PER_SUBMISSION,
@@ -69,6 +71,18 @@
type WorkerSessionRequestIdentity, type WorkerSessionRequestIdentity,
type WorkerSessionTarget, type WorkerSessionTarget,
} from "$lib/workspace/session-observation"; } from "$lib/workspace/session-observation";
import {
applyConsoleHistoryPage,
beginConsoleHistoryRequest,
conversationTurnPreviewsFromLines,
emptyConsoleHistoryState,
failConsoleHistoryRequest,
historyEntries,
setConsoleHistoryTopEdge,
shouldLoadHistoryAtTop,
type ConsoleHistoryState,
} from "$lib/workspace/console/history";
import type { TurnNavigationItem } from "$lib/workspace/console/turn-navigation";
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types"; import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
type Props = { type Props = {
@@ -162,6 +176,7 @@
let workerViewSelectionGeneration = 0; let workerViewSelectionGeneration = 0;
let consoleViewMode = $state<ConsoleViewMode>("overview"); let consoleViewMode = $state<ConsoleViewMode>("overview");
let consoleBodyElement: HTMLElement | null = null; let consoleBodyElement: HTMLElement | null = null;
let turnNavigationElement = $state<HTMLElement | null>(null);
let composerInputElement = $state< let composerInputElement = $state<
(SvelteComponent & ComposerInputHandle) | null (SvelteComponent & ComposerInputHandle) | null
>(null); >(null);
@@ -176,6 +191,8 @@
let consoleProjection = $state.raw<ConsoleProjection>( let consoleProjection = $state.raw<ConsoleProjection>(
consoleProjector.snapshot(), consoleProjector.snapshot(),
); );
let historyByView = $state.raw<Record<string, ConsoleHistoryState>>({});
const pendingHistoryRefreshes = new Set<string>();
let pendingObservationEvents: ConsoleEventInput[] = []; let pendingObservationEvents: ConsoleEventInput[] = [];
let pendingInitialSnapshotApplication: { let pendingInitialSnapshotApplication: {
eventId: string; eventId: string;
@@ -205,10 +222,30 @@
), ),
); );
const selectedConsoleProjection = $derived(selectedWorkerView.console); const selectedConsoleProjection = $derived(selectedWorkerView.console);
const lines = $derived( const selectedHistoryKey = $derived(
`${workspaceId}:${runtimeId ?? ""}:${workerId ?? ""}:${selectedWorkerViewSessionId ?? "main"}`,
);
const selectedHistory = $derived(
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
);
const currentLines = $derived(
projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode), projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode),
); );
const turns = $derived(consoleTurns(lines)); const committedHistoryLines = $derived(
projectConsoleLines(
projectSessionHistoryEntries(
historyEntries(selectedHistory),
selectedConsoleProjection.cwd,
),
consoleViewMode,
),
);
const lines = $derived(
mergeCommittedHistoryLines(committedHistoryLines, currentLines),
);
const turnNavigationItems = $derived(
conversationTurnPreviewsFromLines(lines),
);
const tasks = $derived(selectedConsoleProjection.tasks); const tasks = $derived(selectedConsoleProjection.tasks);
const diagnostics = $derived( const diagnostics = $derived(
mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics), mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics),
@@ -264,6 +301,233 @@
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
type WorkerSessionHistoryResponse =
| { availability: "page"; page: SessionHistoryPage }
| {
availability: "unavailable";
reason: string;
message: string;
};
function mainHistoryKey(target: ConsoleTarget): string {
return `${target.workspaceId}:${target.runtimeId}:${target.workerId}:main`;
}
function historyStateFor(key: string): ConsoleHistoryState {
return historyByView[key] ?? emptyConsoleHistoryState();
}
function setHistoryState(key: string, state: ConsoleHistoryState) {
historyByView = { ...historyByView, [key]: state };
}
async function requestHistoryPage(
target: ConsoleTarget,
key: string,
cursor: string | null,
token: number,
) {
const started = beginConsoleHistoryRequest(historyStateFor(key), cursor);
if (!started) return;
setHistoryState(key, started);
const anchors = cursor === null ? null : captureHistoryAnchors();
const query = new URLSearchParams({ limit: "5" });
if (cursor) query.set("cursor", cursor);
const path = workerApiPath(
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/session/history?${query}`,
);
try {
const response = await fetch(path, {
credentials: "same-origin",
headers: { accept: "application/json" },
});
if (response.status === 404 || response.status === 405) {
throw new Error("Earlier conversation is not supported by this Runtime version.");
}
if (!response.ok) {
throw new Error(`History request failed (${response.status}).`);
}
const payload = (await response.json()) as WorkerSessionHistoryResponse;
if (token !== reloadToken || mainHistoryKey(target) !== key) return;
if (pendingHistoryRefreshes.has(key)) return;
if (payload.availability === "unavailable") {
setHistoryState(
key,
failConsoleHistoryRequest(historyStateFor(key), payload.message),
);
return;
}
setHistoryState(
key,
applyConsoleHistoryPage(historyStateFor(key), payload.page, cursor),
);
await tick();
restoreHistoryAnchors(anchors);
} catch (error) {
if (
token !== reloadToken ||
mainHistoryKey(target) !== key ||
pendingHistoryRefreshes.has(key)
)
return;
setHistoryState(
key,
failConsoleHistoryRequest(
historyStateFor(key),
error instanceof Error ? error.message : String(error),
),
);
} finally {
const refreshAgain = pendingHistoryRefreshes.delete(key);
if (
refreshAgain &&
token === reloadToken &&
mainHistoryKey(target) === key
) {
const stale = historyStateFor(key);
setHistoryState(key, {
...stale,
status: stale.turns.length > 0 ? "ready" : "idle",
error: null,
requestedCursor: null,
});
void requestHistoryPage(target, key, null, token);
}
}
}
type HistoryAnchor = { id: string; top: number };
type HistoryAnchors = {
transcript: HistoryAnchor | null;
navigation: HistoryAnchor | null;
};
function captureElementAnchor(
container: HTMLElement | null,
selector: string,
dataKey: "consoleLineId" | "turnId",
): HistoryAnchor | null {
const first = container?.querySelector<HTMLElement>(selector);
const id = first?.dataset[dataKey] ?? "";
return first && id ? { id, top: first.getBoundingClientRect().top } : null;
}
function captureHistoryAnchors(): HistoryAnchors {
return {
transcript: captureElementAnchor(
consoleBodyElement,
"[data-console-line-id]",
"consoleLineId",
),
navigation: captureElementAnchor(
turnNavigationElement,
"[data-turn-id]",
"turnId",
),
};
}
function restoreElementAnchor(
container: HTMLElement | null,
selector: string,
anchor: HistoryAnchor | null,
) {
if (!anchor || !container) return;
const target = container.querySelector<HTMLElement>(selector);
if (!target) return;
container.scrollTop += target.getBoundingClientRect().top - anchor.top;
}
function restoreHistoryAnchors(anchors: HistoryAnchors | null) {
if (!anchors) return;
restoreElementAnchor(
consoleBodyElement,
`[data-console-line-id="${CSS.escape(anchors.transcript?.id ?? "")}"]`,
anchors.transcript,
);
restoreElementAnchor(
turnNavigationElement,
`[data-turn-id="${CSS.escape(anchors.navigation?.id ?? "")}"]`,
anchors.navigation,
);
}
function loadEarlierHistory() {
const target = consoleTarget;
if (!target || selectedWorkerViewSessionId !== null) return;
const key = mainHistoryKey(target);
const state = historyStateFor(key);
if (!state.hasMore || !state.cursor) return;
void requestHistoryPage(target, key, state.cursor, reloadToken);
}
function retryHistoryLoad() {
const target = consoleTarget;
if (!target || selectedWorkerViewSessionId !== null) return;
const key = mainHistoryKey(target);
const state = historyStateFor(key);
void requestHistoryPage(
target,
key,
state.hasMore ? state.cursor : null,
reloadToken,
);
}
function fenceCurrentHistoryForRewind() {
const target = consoleTarget;
if (!target) return;
const key = mainHistoryKey(target);
const current = historyStateFor(key);
const loading = current.status === "loading";
const reset = emptyConsoleHistoryState();
setHistoryState(key, {
...reset,
status: loading ? "loading" : "idle",
requestedCursor: loading ? current.requestedCursor : null,
topEdgeArmed: current.topEdgeArmed,
});
if (loading) pendingHistoryRefreshes.add(key);
}
function refreshCurrentHistory() {
const target = consoleTarget;
if (!target) return;
const key = mainHistoryKey(target);
if (historyStateFor(key).status === "loading") {
pendingHistoryRefreshes.add(key);
return;
}
void requestHistoryPage(target, key, null, reloadToken);
}
function handleHistoryTopEdge(atTop: boolean) {
if (selectedWorkerViewSessionId !== null) return;
const key = selectedHistoryKey;
const state = historyStateFor(key);
if (shouldLoadHistoryAtTop(state, atTop)) {
loadEarlierHistory();
} else if (!atTop) {
const rearmed = setConsoleHistoryTopEdge(state, false);
if (rearmed !== state) setHistoryState(key, rearmed);
}
}
function jumpToConversationTurn(item: TurnNavigationItem) {
const line = lines.find(
(candidate) => candidate.entryId === item.lineId || candidate.id === item.lineId,
);
const target = line
? consoleBodyElement?.querySelector(
`[data-console-line-id="${CSS.escape(line.id)}"]`,
)
: null;
if (target instanceof HTMLElement && line) {
selectConsoleTurn(line.id);
}
}
async function loadWorker(target: ConsoleTarget, token: number) { async function loadWorker(target: ConsoleTarget, token: number) {
workerError = null; workerError = null;
try { try {
@@ -406,6 +670,22 @@
liveWorkerState = consoleProjection.status === "shutdown" liveWorkerState = consoleProjection.status === "shutdown"
? "shutdown" ? "shutdown"
: workerStateFromSnapshot(consoleProjection.workerState); : workerStateFromSnapshot(consoleProjection.workerState);
if (
eventBatch.some((event) => event.event.event === "rewind_applied")
) {
fenceCurrentHistoryForRewind();
}
if (
eventBatch.some((event) =>
event.event.event === "snapshot" ||
event.event.event === "segment_rotated" ||
event.event.event === "rewind_applied" ||
event.event.event === "user_message" ||
event.event.event === "session_entry_committed"
)
) {
refreshCurrentHistory();
}
if ( if (
initialSnapshotApplication && initialSnapshotApplication &&
eventBatch.some( eventBatch.some(
@@ -1338,6 +1618,7 @@
} }
autoFollowConsole = isNearConsoleBottom(consoleBodyElement); autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
rememberConsoleWorkerViewScroll(); rememberConsoleWorkerViewScroll();
handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1);
} }
async function scrollConsoleToBottom() { async function scrollConsoleToBottom() {
@@ -1543,40 +1824,67 @@
<div class:with-task-pane={taskPaneOpen} class="console-history"> <div class:with-task-pane={taskPaneOpen} class="console-history">
<section class="console-body"> <section class="console-body">
<div <div
class="console-scroll" class="console-scroll"
bind:this={consoleBodyElement} bind:this={consoleBodyElement}
onscroll={handleConsoleScroll} onscroll={handleConsoleScroll}
>
<article
class="card console-card worker-console-card"
aria-label={`${selectedWorkerView.label} transcript`}
> >
<article {#if workerError}
class="card console-card worker-console-card" <p class="error">{workerError}</p>
aria-label={`${selectedWorkerView.label} transcript`} {/if}
>
{#if workerError}
<p class="error">{workerError}</p>
{/if}
<ConsoleDisplayStateView {#if selectedWorkerViewSessionId === null && (selectedHistory.hasMore || selectedHistory.error || selectedHistory.status === "loading")}
state={consoleDisplayState} <div class="conversation-history-boundary">
hasContent={lines.length > 0} <button
hasUnfilteredContent={selectedConsoleProjection.lines.length > 0} type="button"
onRetry={retryConsoleLoad} disabled={selectedHistory.status === "loading"}
/> onclick={selectedHistory.error ? retryHistoryLoad : loadEarlierHistory}
>
{selectedHistory.status === "loading"
? "Loading earlier conversation…"
: selectedHistory.error
? "Earlier conversation unavailable · retry"
: "Earlier conversation available · load 5 turns"}
</button>
</div>
{:else if selectedWorkerViewSessionId === null && selectedHistory.status === "ready"}
<div class="conversation-history-boundary complete">Start of conversation</div>
{/if}
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0} <ConsoleDisplayStateView
<ol class="console-log"> state={consoleDisplayState}
{#each lines as item (item.id)} hasContent={lines.length > 0}
<ConsoleLineItem {item} /> hasUnfilteredContent={selectedConsoleProjection.lines.length > 0}
{/each} onRetry={retryConsoleLoad}
</ol> />
{/if}
</article> {#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
</div> <ol class="console-log">
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && turns.length > 0} {#each lines as item (item.id)}
{#key `${workerId}:${selectedWorkerView.sessionId}`} <ConsoleLineItem {item} />
<ConsoleTurnNavigation {turns} onSelect={selectConsoleTurn} /> {/each}
{/key} </ol>
{/if} {/if}
</article>
</div>
<ConsoleTurnNavigation
items={turnNavigationItems}
hasMore={selectedWorkerViewSessionId === null && selectedHistory.hasMore}
loading={selectedHistory.status === "loading"}
error={selectedWorkerViewSessionId === null
? selectedHistory.error
: "Earlier history is unavailable for direct SubWorker views."}
onLoadMore={selectedHistory.error ? retryHistoryLoad : loadEarlierHistory}
onTopEdgeChange={handleHistoryTopEdge}
onTurnClick={jumpToConversationTurn}
historyAvailable={selectedWorkerViewSessionId === null}
bind:element={turnNavigationElement}
/>
</section> </section>
{#if taskPaneOpen} {#if taskPaneOpen}
@@ -2041,6 +2349,36 @@
display: none; display: none;
} }
.conversation-history-boundary {
display: flex;
justify-content: center;
color: var(--text-muted);
font-size: var(--font-size-compact);
padding-bottom: var(--space-3);
}
.conversation-history-boundary button {
border: 0;
background: transparent;
color: var(--tui-cyan);
cursor: pointer;
font: inherit;
}
.conversation-history-boundary button:disabled {
cursor: wait;
opacity: 0.65;
}
.conversation-history-boundary.complete::before,
.conversation-history-boundary.complete::after {
content: "";
align-self: center;
width: 2rem;
margin: 0 var(--space-2);
border-top: 1px solid var(--line);
}
.pending-submissions { .pending-submissions {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -6,10 +6,12 @@ import {
render, render,
screen, screen,
waitFor, waitFor,
within,
} from "@testing-library/svelte"; } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type { import type {
Event as ProtocolEvent, Event as ProtocolEvent,
SessionConversationTurn,
SessionSnapshot, SessionSnapshot,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import type { Worker } from "$lib/workspace/sidebar/types"; import type { Worker } from "$lib/workspace/sidebar/types";
@@ -106,6 +108,63 @@ function sessionWithUserMessage(content: string): SessionSnapshot {
}; };
} }
function emptyHistoryPage() {
return {
availability: "page",
page: {
session_id: "session-a",
lineage_id: "lineage-a",
compact_ancestor_lineage_ids: [],
turns: [],
next_cursor: null,
has_more: false,
},
};
}
function historyPage(
indices: number[],
cursor: string | null,
lineageId = "lineage-a",
parentLineage?: {
lineage_id: string;
adopted_through_turn?: SessionConversationTurn | null;
},
) {
return {
availability: "page",
page: {
session_id: "session-a",
lineage_id: lineageId,
compact_ancestor_lineage_ids: [],
parent_lineage: parentLineage,
turns: indices.map((index) => ({
turn_id: `user-${index}`,
entries: [
{
entry_id: `user-${index}`,
timestamp: index,
provenance: "human_input",
kind: "message",
role: "user",
content: [{ kind: "text", text: `question ${index}` }],
},
{
entry_id: `assistant-${index}`,
timestamp: index,
provenance: "model_output",
kind: "message",
role: "assistant",
content: [{ kind: "text", text: `done ${index}` }],
},
],
})),
next_cursor: cursor,
has_more: cursor !== null,
},
};
}
function snapshotEvent(session = emptySession()): ProtocolEvent { function snapshotEvent(session = emptySession()): ProtocolEvent {
return { return {
event: "snapshot", event: "snapshot",
@@ -367,18 +426,30 @@ test("applies retained empty and nonempty snapshots before showing their read-on
snapshot: sessionWithUserMessage("retained message"), snapshot: sessionWithUserMessage("retained message"),
})); }));
expect(await screen.findByText("retained message")).not.toBeNull(); expect(
await within(screen.getByRole("article", { name: "main transcript" }))
.findByText("retained message"),
).not.toBeNull();
expect(screen.getByText("Read-only retained conversation")).not.toBeNull(); expect(screen.getByText("Read-only retained conversation")).not.toBeNull();
expect(screen.queryByText("No conversation to display")).toBeNull(); expect(screen.queryByText("No conversation to display")).toBeNull();
}); });
test("shows bounded initial failures and retries explicitly", async () => { test("shows bounded initial failures and retries explicitly", async () => {
const fetchMock = vi.fn() let sessionRequests = 0;
.mockResolvedValueOnce(new Response(null, { status: 503 })) const fetchMock = vi.fn((input: RequestInfo | URL) => {
.mockResolvedValueOnce(Response.json({ if (String(input).includes("/session/history")) {
availability: "retained_snapshot", return Promise.resolve(Response.json(emptyHistoryPage()));
snapshot: emptySession(), }
})); sessionRequests += 1;
return Promise.resolve(
sessionRequests === 1
? new Response(null, { status: 503 })
: Response.json({
availability: "retained_snapshot",
snapshot: emptySession(),
}),
);
});
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
render(ConsolePage, { data: pageData() }); render(ConsolePage, { data: pageData() });
@@ -388,7 +459,7 @@ test("shows bounded initial failures and retries explicitly", async () => {
await fireEvent.click(screen.getByRole("button", { name: "Retry" })); await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("No conversation to display")).not.toBeNull(); expect(await screen.findByText("No conversation to display")).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(2); expect(sessionRequests).toBe(2);
}); });
test("distinguishes typed unavailability and an initial live connection closure", async () => { test("distinguishes typed unavailability and an initial live connection closure", async () => {
@@ -426,17 +497,165 @@ test("preserves rendered content while reconnecting", async () => {
latestListener().onFrame( latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("kept message")), subscribedFrame(sessionWithUserMessage("kept message")),
); );
expect(await screen.findByText("kept message")).not.toBeNull(); expect(
await within(screen.getByRole("article", { name: "main transcript" }))
.findByText("kept message"),
).not.toBeNull();
latestListener().onStatus?.("closed", "Workspace subscription disconnected"); latestListener().onStatus?.("closed", "Workspace subscription disconnected");
expect(await screen.findByText("Conversation updates are unavailable")).not expect(await screen.findByText("Conversation updates are unavailable")).not
.toBeNull(); .toBeNull();
expect(screen.getByText("kept message")).not.toBeNull(); expect(
within(screen.getByRole("article", { name: "main transcript" })).getByText(
"kept message",
),
).not.toBeNull();
expect(screen.queryByText("No conversation to display")).toBeNull(); expect(screen.queryByText("No conversation to display")).toBeNull();
latestListener().onStatus?.("connecting", "resubscribing"); latestListener().onStatus?.("connecting", "resubscribing");
expect(await screen.findByText("Refreshing conversation")).not.toBeNull(); expect(await screen.findByText("Refreshing conversation")).not.toBeNull();
expect(screen.getByText("kept message")).not.toBeNull(); expect(
within(screen.getByRole("article", { name: "main transcript" })).getByText(
"kept message",
),
).not.toBeNull();
});
test("reconnect snapshot refreshes lineage and removes stale sibling history", async () => {
let historyRequests = 0;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
if (!String(input).includes("/session/history")) {
return Promise.resolve(
Response.json({ availability: "live_protocol" }),
);
}
historyRequests += 1;
const response = historyPage([historyRequests], null);
if (historyRequests === 2) {
response.page.lineage_id = "lineage-b";
}
return Promise.resolve(Response.json(response));
}),
);
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("lineage A current")),
);
await waitFor(() =>
expectHistoryTurn("question 1")
);
latestListener().onStatus?.("closed", "disconnected during rewind");
latestListener().onStatus?.("connecting", "reconnecting");
latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("lineage B current")),
);
await waitFor(() =>
expectHistoryTurn("question 2")
);
expect(screen.queryByText("question 1")).toBeNull();
expect(historyRequests).toBe(2);
});
test("snapshot supersedes an in-flight page before stale lineage can render", async () => {
const staleHistory = deferred<Response>();
let historyRequests = 0;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
if (!String(input).includes("/session/history")) {
return Promise.resolve(
Response.json({ availability: "live_protocol" }),
);
}
historyRequests += 1;
if (historyRequests === 1) return staleHistory.promise;
const response = historyPage([2], null);
response.page.lineage_id = "lineage-b";
return Promise.resolve(Response.json(response));
}),
);
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("lineage A current")),
);
await waitFor(() => expect(historyRequests).toBe(1));
latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("lineage B current")),
);
staleHistory.resolve(Response.json(historyPage([1], null)));
await waitFor(() =>
expectHistoryTurn("question 2")
);
expect(screen.queryByText("question 1")).toBeNull();
expect(historyRequests).toBe(2);
});
test("fork refresh reconciles an in-flight older page to the adopted prefix", async () => {
const staleOlder = deferred<Response>();
let historyRequests = 0;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
if (!String(input).includes("/session/history")) {
return Promise.resolve(
Response.json({ availability: "live_protocol" }),
);
}
historyRequests += 1;
if (historyRequests === 1) {
return Promise.resolve(
Response.json(historyPage([46, 47, 48, 49, 50], "older-46")),
);
}
if (historyRequests === 2) return staleOlder.promise;
return Promise.resolve(Response.json(historyPage(
[101, 102, 103, 104, 105],
"older-101",
"lineage-fork",
{
lineage_id: "lineage-a",
adopted_through_turn: historyPage([48], null).page
.turns[0] as SessionConversationTurn,
},
)));
}),
);
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame());
await waitFor(() =>
expectHistoryTurn("question 50")
);
const transcriptScroller = screen.getByLabelText("main transcript")
.parentElement as HTMLElement;
transcriptScroller.scrollTop = 0;
await fireEvent.scroll(transcriptScroller);
await waitFor(() => expect(historyRequests).toBe(2));
latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("fork current")),
);
staleOlder.resolve(Response.json(historyPage([41, 42, 43, 44, 45], null)));
await waitFor(() =>
expectHistoryTurn("question 101")
);
expectHistoryTurn("question 48");
expect(screen.queryByText("question 49")).toBeNull();
expectHistoryTurn("question 41");
expect(historyRequests).toBe(3);
}); });
test("route changes clear prior content and fence stale Session responses", async () => { test("route changes clear prior content and fence stale Session responses", async () => {
@@ -444,6 +663,9 @@ test("route changes clear prior content and fence stale Session responses", asyn
const current = deferred<Response>(); const current = deferred<Response>();
let firstWorkerLoads = 0; let firstWorkerLoads = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => { const fetchMock = vi.fn((input: RequestInfo | URL) => {
if (String(input).includes("/session/history")) {
return Promise.resolve(Response.json(emptyHistoryPage()));
}
if (String(input).includes("worker-b")) { if (String(input).includes("worker-b")) {
return current.promise.then((value) => value.clone()); return current.promise.then((value) => value.clone());
} }
@@ -459,7 +681,10 @@ test("route changes clear prior content and fence stale Session responses", asyn
latestListener().onFrame( latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("previous worker content")), subscribedFrame(sessionWithUserMessage("previous worker content")),
); );
expect(await screen.findByText("previous worker content")).not.toBeNull(); expect(
await within(screen.getByRole("article", { name: "main transcript" }))
.findByText("previous worker content"),
).not.toBeNull();
latestListener().onStatus?.("closed", "reload old worker"); latestListener().onStatus?.("closed", "reload old worker");
await fireEvent.click( await fireEvent.click(
@@ -467,7 +692,10 @@ test("route changes clear prior content and fence stale Session responses", asyn
); );
await waitFor(() => expect(firstWorkerLoads).toBe(2)); await waitFor(() => expect(firstWorkerLoads).toBe(2));
await view.rerender({ data: pageData("worker-b", "runtime-b") }); await view.rerender({ data: pageData("worker-b", "runtime-b") });
expect(screen.queryByText("previous worker content")).toBeNull(); expect(
within(screen.getByRole("article", { name: "main transcript" }))
.queryByText("previous worker content"),
).toBeNull();
expect(screen.getByRole("status").textContent).toContain( expect(screen.getByRole("status").textContent).toContain(
"Loading conversation", "Loading conversation",
); );
@@ -476,7 +704,12 @@ test("route changes clear prior content and fence stale Session responses", asyn
availability: "retained_snapshot", availability: "retained_snapshot",
snapshot: sessionWithUserMessage("current worker content"), snapshot: sessionWithUserMessage("current worker content"),
})); }));
expect(await screen.findByText("current worker content")).not.toBeNull(); expect(
await within(screen.getByRole("article", { name: "main transcript" }))
.findByText(
"current worker content",
),
).not.toBeNull();
staleReload.resolve(Response.json({ staleReload.resolve(Response.json({
availability: "unavailable", availability: "unavailable",
@@ -484,6 +717,213 @@ test("route changes clear prior content and fence stale Session responses", asyn
})); }));
await settleMicrotasks(); await settleMicrotasks();
expect(screen.queryByText("stale worker error")).toBeNull(); expect(screen.queryByText("stale worker error")).toBeNull();
expect(screen.queryByText("previous worker content")).toBeNull(); expect(
expect(screen.getByText("current worker content")).not.toBeNull(); within(screen.getByRole("article", { name: "main transcript" }))
.queryByText("previous worker content"),
).toBeNull();
expect(
within(screen.getByRole("article", { name: "main transcript" })).getByText(
"current worker content",
),
).not.toBeNull();
}); });
test("loads retained conversation turns five at a time into the log and turn bar", async () => {
const historyPages = [
{
availability: "page",
page: {
session_id: "session-a",
lineage_id: "lineage-a",
compact_ancestor_lineage_ids: [],
turns: [8, 9, 10, 11, 12].map((index) => ({
turn_id: `user-${index}`,
entries: [
{
entry_id: `user-${index}`,
timestamp: index,
provenance: "human_input",
kind: "message",
role: "user",
content: [{ kind: "text", text: `question ${index}` }],
},
{
entry_id: `assistant-${index}`,
timestamp: index,
provenance: "model_output",
kind: "message",
role: "assistant",
content: [{ kind: "text", text: `done ${index}` }],
},
],
})),
next_cursor: "older-8",
has_more: true,
},
},
{
availability: "page",
page: {
session_id: "session-a",
lineage_id: "lineage-a",
compact_ancestor_lineage_ids: [],
turns: [3, 4, 5, 6, 7].map((index) => ({
turn_id: `user-${index}`,
entries: [{
entry_id: `user-${index}`,
timestamp: index,
provenance: "human_input",
kind: "message",
role: "user",
content: [{ kind: "text", text: `question ${index}` }],
}],
})),
next_cursor: "older-3",
has_more: true,
},
},
historyPage([1, 2], null),
];
let historyRequests = 0;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
if (String(input).includes("/session/history")) {
return Promise.resolve(Response.json(historyPages[historyRequests++]));
}
return Promise.resolve(Response.json({ availability: "live_protocol" }));
}),
);
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame());
await waitFor(() =>
expectHistoryTurn("question 8")
);
expect(screen.getAllByText("done 12")).toHaveLength(1);
const transcriptScroller = screen.getByLabelText("main transcript")
.parentElement as HTMLElement;
transcriptScroller.scrollTop = 0;
await fireEvent.scroll(transcriptScroller);
await waitFor(() =>
expectHistoryTurn("question 3")
);
expect(historyRequests).toBe(2);
const navigation = screen.getByLabelText("Conversation turns");
const navigationList = navigation.firstElementChild as HTMLElement;
navigationList.scrollTop = 0;
await fireEvent.scroll(navigationList);
await fireEvent.scroll(navigationList);
transcriptScroller.scrollTop = 0;
await fireEvent.scroll(transcriptScroller);
await settleMicrotasks();
expect(historyRequests).toBe(2);
await fireEvent.click(
screen.getByRole("button", {
name: "Earlier conversation available · load 5 turns",
}),
);
await waitFor(() =>
expectHistoryTurn("question 1")
);
expect(screen.getAllByText("Start of conversation")).toHaveLength(1);
expect(historyRequests).toBe(3);
const target = screen.getByText("question 8").closest(".console-line") as HTMLElement;
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 600 } as DOMRect);
vi.spyOn(transcriptScroller, "getBoundingClientRect").mockReturnValue({ top: 200 } as DOMRect);
Object.defineProperties(transcriptScroller, {
scrollHeight: { configurable: true, value: 2000 },
clientHeight: { configurable: true, value: 400 },
});
transcriptScroller.scrollTop = 400;
await fireEvent.click(
screen.getByRole("button", {
name: /^Turn \d+: question 8$/,
}),
);
expect(transcriptScroller.scrollTop).toBe(800);
});
test("keeps live content when history fails and retries the bounded page explicitly", async () => {
let historyRequests = 0;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
if (String(input).includes("/session/history")) {
historyRequests += 1;
return Promise.resolve(
historyRequests === 1
? new Response(null, { status: 503 })
: Response.json(historyPage([1, 2, 3, 4], null)),
);
}
return Promise.resolve(Response.json({ availability: "live_protocol" }));
}),
);
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("current response remains")),
);
expect(
await within(screen.getByRole("article", { name: "main transcript" }))
.findByText("current response remains"),
).not.toBeNull();
await fireEvent.click(
await screen.findByRole("button", {
name: "Earlier conversation unavailable · retry",
}),
);
await waitFor(() =>
expectHistoryTurn("question 1")
);
expect(historyRequests).toBe(2);
expect(
within(screen.getByRole("article", { name: "main transcript" })).getByText(
"current response remains",
),
).not.toBeNull();
expect(screen.getAllByText("Start of conversation")).toHaveLength(1);
});
test("fences delayed history pages when the selected Worker route changes", async () => {
const stale = deferred<Response>();
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
const path = String(input);
if (path.includes("/session/history")) {
return path.includes("worker-b")
? Promise.resolve(Response.json(historyPage([21], null)))
: stale.promise;
}
return Promise.resolve(Response.json({
availability: "retained_snapshot",
snapshot: emptySession(),
}));
}),
);
const view = render(ConsolePage, { data: pageData() });
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() =>
expectHistoryTurn("question 21")
);
stale.resolve(Response.json(historyPage([99], null)));
await settleMicrotasks();
expect(screen.queryByText("question 99")).toBeNull();
expectHistoryTurn("question 21");
});
function expectHistoryTurn(text: string) {
expect(screen.getAllByText(text)).toHaveLength(1);
expect(screen.getByRole("button", { name: new RegExp("^Turn \\d+: " + text + "$") })).not.toBeNull();
}
@@ -12,7 +12,7 @@
parseWorkerLaunchOptionsResponse, parseWorkerLaunchOptionsResponse,
} from '$lib/workspace/api/workers'; } from '$lib/workspace/api/workers';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision'; import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { formatWorkdirPermissions } from '$lib/workspace/settings/workdir-permissions'; import { formatExternalGrantPermissionLevel } from '$lib/workspace/settings/workdir-permissions';
import { import {
buildCreateWorkspaceWorkerRequest, buildCreateWorkspaceWorkerRequest,
defaultWorkerLaunchForm, defaultWorkerLaunchForm,
@@ -40,7 +40,7 @@
: null; : null;
const label = directory.display_name ?? repositoryKey ?? 'External Workdir'; const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
if (directory.source.kind === 'external_grant') { if (directory.source.kind === 'external_grant') {
return `${label} · External · ${formatWorkdirPermissions(directory.source.grant_permissions)}`; return `${label} · External · ${formatExternalGrantPermissionLevel(directory.source.grant_permissions)}`;
} }
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`; return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
} }
+12 -3
View File
@@ -56,7 +56,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
source: { source: {
kind: "external_grant", kind: "external_grant",
grant_id: "grant-1", grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: true }, grant_permissions: { read: true, write: true, command: true },
}, },
materializer_kind: "client_hosted_external", materializer_kind: "client_hosted_external",
status: "active", status: "active",
@@ -72,7 +72,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
} }
if ( if (
!detail.item.source.grant_permissions.read || !detail.item.source.grant_permissions.read ||
detail.item.source.grant_permissions.write || !detail.item.source.grant_permissions.write ||
!detail.item.source.grant_permissions.command !detail.item.source.grant_permissions.command
) { ) {
throw new Error("External Workdir grant permissions were not preserved"); throw new Error("External Workdir grant permissions were not preserved");
@@ -83,6 +83,11 @@ Deno.test("Workdir REST validation rejects missing or misplaced External grant p
for ( for (
const source of [ const source of [
{ kind: "external_grant", grant_id: "grant-1" }, { kind: "external_grant", grant_id: "grant-1" },
{
kind: "external_grant",
grant_id: "grant-1",
grant_permissions: { read: false, write: false, command: true },
},
{ {
kind: "external_grant", kind: "external_grant",
grant_id: "grant-1", grant_id: "grant-1",
@@ -113,7 +118,11 @@ Deno.test("Workdir REST validation rejects missing or misplaced External grant p
rejected = true; rejected = true;
} }
if (!rejected) { if (!rejected) {
throw new Error(`invalid External permission projection was accepted: ${JSON.stringify(source)}`); throw new Error(
`invalid External permission projection was accepted: ${
JSON.stringify(source)
}`,
);
} }
} }
}); });
@@ -2,9 +2,12 @@ declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void; test(name: string, fn: () => void | Promise<void>): void;
}; };
import { formatWorkdirPermissions } from "../src/lib/workspace/settings/workdir-permissions.ts"; import {
formatExternalGrantPermissionLevel,
formatWorkdirPermissions,
} from "../src/lib/workspace/settings/workdir-permissions.ts";
Deno.test("Workdir permissions render independent categories in stable order", () => { Deno.test("effective Workdir permissions render partial intersections without widening", () => {
const cases = [ const cases = [
[{ read: true, write: false, command: false }, "READ"], [{ read: true, write: false, command: false }, "READ"],
[{ read: true, write: true, command: false }, "READ · WRITE"], [{ read: true, write: true, command: false }, "READ · WRITE"],
@@ -20,3 +23,32 @@ Deno.test("Workdir permissions render independent categories in stable order", (
} }
} }
}); });
Deno.test("External grant levels show inherited authority and reject partial grants", () => {
const cases = [
[{ read: true, write: false, command: false }, "READ"],
[{ read: true, write: true, command: false }, "WRITE (includes READ)"],
[
{ read: true, write: true, command: true },
"COMMAND (includes WRITE + READ)",
],
] as const;
for (const [permissions, expected] of cases) {
const actual = formatExternalGrantPermissionLevel(permissions);
if (actual !== expected) {
throw new Error(`expected ${expected}, got ${actual}`);
}
}
let rejected = false;
try {
formatExternalGrantPermissionLevel({
read: false,
write: false,
command: true,
});
} catch {
rejected = true;
}
if (!rejected) throw new Error("command-only External grant was accepted");
});