chore: merge develop while preserving console interactions
This commit is contained in:
Generated
+3
@@ -6383,6 +6383,7 @@ dependencies = [
|
||||
name = "yoi-workspace-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agen",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -6410,6 +6411,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml",
|
||||
"tools",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -6421,6 +6423,7 @@ dependencies = [
|
||||
"workdir",
|
||||
"worker",
|
||||
"worker-runtime",
|
||||
"yoi",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ use thiserror::Error;
|
||||
|
||||
pub use glob::run_glob;
|
||||
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 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]
|
||||
fn cancellation_stops_a_read_already_in_progress() {
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
@@ -7,8 +7,9 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{
|
||||
AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind,
|
||||
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult,
|
||||
StatRequest, StatResult, WriteRequest, WriteResult, direct_symlink,
|
||||
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest,
|
||||
ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
direct_symlink,
|
||||
};
|
||||
|
||||
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(
|
||||
root: &Path,
|
||||
request: WriteRequest,
|
||||
|
||||
@@ -207,6 +207,36 @@ pub struct ReadResult {
|
||||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WriteRequest {
|
||||
|
||||
@@ -838,6 +838,56 @@ pub struct SessionSnapshot {
|
||||
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)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[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
|
||||
/// instances show the same user line that reconnect snapshots replay.
|
||||
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>,
|
||||
},
|
||||
/// 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.
|
||||
///
|
||||
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
||||
@@ -2765,6 +2824,7 @@ mod tests {
|
||||
#[test]
|
||||
fn event_user_message_roundtrip() {
|
||||
let event = Event::UserMessage {
|
||||
entry_id: Some("entry-1".to_string()),
|
||||
segments: vec![Segment::text("hello 世界")],
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
@@ -2775,7 +2835,7 @@ mod tests {
|
||||
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::UserMessage { segments } => {
|
||||
Event::UserMessage { segments, .. } => {
|
||||
assert_eq!(segments.len(), 1);
|
||||
match &segments[0] {
|
||||
Segment::Text { content } => assert_eq!(content, "hello 世界"),
|
||||
|
||||
@@ -11,12 +11,13 @@ use crate::{
|
||||
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
|
||||
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
||||
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
|
||||
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
|
||||
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
||||
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
||||
ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
||||
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
|
||||
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
|
||||
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||
WorkerStateSnapshot, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
|
||||
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::<SessionSnapshotEntryData>(&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::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
||||
|
||||
@@ -655,6 +655,41 @@ pub struct WorkerSessionRequest {
|
||||
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)]
|
||||
pub struct RetainedSessionIdentity {
|
||||
pub session_id: String,
|
||||
@@ -718,6 +753,17 @@ pub trait RuntimeApi {
|
||||
#[query] request: WorkerSessionRequest,
|
||||
) -> 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)]
|
||||
async fn create_worker(
|
||||
&self,
|
||||
@@ -1053,6 +1099,17 @@ mod tests {
|
||||
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(
|
||||
&self,
|
||||
_request: CreateWorkerRequest,
|
||||
@@ -1174,10 +1231,22 @@ mod tests {
|
||||
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]
|
||||
fn contract_inventory_is_complete_and_unique() {
|
||||
let operations = RuntimeApiMetadata::OPERATIONS;
|
||||
assert_eq!(operations.len(), 16);
|
||||
assert_eq!(operations.len(), 17);
|
||||
let mut routes = operations
|
||||
.iter()
|
||||
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
||||
|
||||
@@ -389,6 +389,20 @@ pub struct WorkspaceWorkerSessionResponse {
|
||||
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)]
|
||||
pub trait ServerApi {
|
||||
#[get("/health", status = 200, error_status = 400)]
|
||||
@@ -1141,6 +1155,20 @@ pub trait ServerApi {
|
||||
#[path] worker_id: String,
|
||||
) -> 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(
|
||||
"/api/workers",
|
||||
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)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalWorkdirPermissions {
|
||||
/// 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,
|
||||
/// Create or update files with Write and Edit. Existing-file mutation also
|
||||
/// requires `read` so callers can satisfy the read-before-write contract.
|
||||
/// Create or update files with Write and Edit. WRITE includes READ so callers
|
||||
/// can satisfy the read-before-write contract.
|
||||
pub write: bool,
|
||||
/// Execute commands as the sharing CLI user on the provider host. This is
|
||||
/// explicit authority and does not imply either filesystem category.
|
||||
/// Execute commands as the sharing CLI user on the provider host. COMMAND
|
||||
/// includes WRITE and READ, remains explicit, and is not sandboxed.
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
read: true,
|
||||
write: false,
|
||||
command: false,
|
||||
}
|
||||
Self::READ
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11462,7 +11534,7 @@ mod openapi_artifact_tests {
|
||||
];
|
||||
// Session observation retains its existing explicit non-OpenAPI boundary independently of
|
||||
// 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 BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
|
||||
|
||||
|
||||
@@ -37,23 +37,31 @@ use std::path::Path;
|
||||
/// Read an existing retained file without updating its access timestamp.
|
||||
/// Observation fails closed when the platform cannot provide that guarantee.
|
||||
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"))]
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOATIME)
|
||||
.open(path)?;
|
||||
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)
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
{
|
||||
let _ = path;
|
||||
let _ = (path, max_bytes);
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"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 paste_artifact::PasteArtifactLimits;
|
||||
pub use public_snapshot::{
|
||||
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, RetainedSessionIdentity, RetainedSessionSnapshot,
|
||||
RetainedSnapshotReadError, project_session_snapshot, read_retained_session_snapshot,
|
||||
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
||||
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
||||
DEFAULT_RETAINED_HISTORY_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::{
|
||||
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,9 @@ pub enum StoreError {
|
||||
#[error("log corrupted at line {line}: {message}")]
|
||||
Corrupt { line: usize, message: String },
|
||||
|
||||
#[error("retained observation read limit exceeded")]
|
||||
ReadLimitExceeded,
|
||||
|
||||
#[error("paste artifact storage is unavailable")]
|
||||
PasteArtifactUnsupported,
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::{
|
||||
use protocol::{PasteArtifactRef, UploadedFileRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
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::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
@@ -50,6 +50,106 @@ struct SessionManifest {
|
||||
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 {
|
||||
/// Open the Session store rooted at `<worker-aggregate>/session`.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
|
||||
@@ -119,6 +219,19 @@ impl WorkerSessionStore {
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
) -> 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 {
|
||||
line: 0,
|
||||
message: "Worker Session identity lock poisoned".to_string(),
|
||||
@@ -131,14 +244,210 @@ impl WorkerSessionStore {
|
||||
});
|
||||
}
|
||||
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 {
|
||||
StoreError::NotFound(segment_id)
|
||||
} else {
|
||||
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> {
|
||||
@@ -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> {
|
||||
let segments = root.join(SEGMENTS_DIR);
|
||||
if !segments.exists() {
|
||||
|
||||
@@ -117,7 +117,7 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
|
||||
.expect("protocol event")
|
||||
.expect("worker event")
|
||||
{
|
||||
Event::UserMessage { segments }
|
||||
Event::UserMessage { segments, .. }
|
||||
if format!("{segments:?}").contains("read the probe") =>
|
||||
{
|
||||
saw_user_message = true;
|
||||
|
||||
@@ -7,12 +7,14 @@ use agen::tool::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
use workdir::{ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
|
||||
/// Maximum image body accepted for one model request.
|
||||
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. \
|
||||
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,
|
||||
)?;
|
||||
let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?;
|
||||
let result = selected
|
||||
.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,
|
||||
})
|
||||
let bytes = read_image_bytes(selected.session.as_ref(), path.clone())
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
|
||||
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(|| {
|
||||
let mime_type = detect_image_mime(&bytes).ok_or_else(|| {
|
||||
ToolError::InvalidArgument(
|
||||
"unsupported image; expected PNG, JPEG, GIF, or WebP bytes".to_string(),
|
||||
)
|
||||
})?;
|
||||
let bytes = result.bytes.len();
|
||||
let byte_count = bytes.len();
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Attached image {path} ({mime_type}, {bytes} bytes)"),
|
||||
summary: format!("Attached image {path} ({mime_type}, {byte_count} bytes)"),
|
||||
content: None,
|
||||
attachments: vec![Attachment::Image(ImageAttachment::new(
|
||||
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 {
|
||||
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 |
@@ -18,7 +18,9 @@ use tools::{
|
||||
Tracker, core_builtin_tools, routed_builtin_tools, routed_view_image_tool, view_image_tool,
|
||||
};
|
||||
use workdir::{
|
||||
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter,
|
||||
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirPath, WorkdirSessionHandle,
|
||||
WorkdirSessionRouter, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
|
||||
WorkdirToolScopeRule,
|
||||
};
|
||||
|
||||
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
||||
@@ -94,6 +96,33 @@ fn setup_routed() -> (
|
||||
(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 {
|
||||
tool.execute(&input.to_string(), Default::default())
|
||||
.await
|
||||
@@ -399,6 +428,301 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
||||
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]
|
||||
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
||||
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 session: WorkdirSessionHandle =
|
||||
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();
|
||||
let definition = view_image_tool(session);
|
||||
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.data(), png);
|
||||
let serialized = serde_json::to_string(&output).unwrap();
|
||||
assert!(!serialized.contains("private-image-body"));
|
||||
assert!(serialized.contains("attachments"));
|
||||
let restored: agen::tool::ToolOutput = serde_json::from_str(&serialized).unwrap();
|
||||
let agen::tool::Attachment::Image(restored_image) = &restored.attachments[0];
|
||||
|
||||
@@ -1148,7 +1148,7 @@ impl App {
|
||||
Event::PendingSubmissionsChanged { pending } => {
|
||||
self.pending_submissions = pending;
|
||||
}
|
||||
Event::UserMessage { segments } => {
|
||||
Event::UserMessage { segments, .. } => {
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
@@ -1168,6 +1168,9 @@ impl App {
|
||||
}
|
||||
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 } => {
|
||||
self.apply_system_item(&item);
|
||||
self.assistant_streaming = false;
|
||||
@@ -3391,6 +3394,7 @@ mod completion_flow_tests {
|
||||
]),
|
||||
});
|
||||
app.handle_worker_event(Event::UserMessage {
|
||||
entry_id: None,
|
||||
segments: vec![Segment::text("first persisted message")],
|
||||
});
|
||||
|
||||
@@ -3409,6 +3413,7 @@ mod completion_flow_tests {
|
||||
assert_eq!(input_text(&app), "");
|
||||
|
||||
app.handle_worker_event(Event::UserMessage {
|
||||
entry_id: None,
|
||||
segments: submitted,
|
||||
});
|
||||
// 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 submitted = submit_text(&mut app, "original submit");
|
||||
app.handle_worker_event(Event::UserMessage {
|
||||
entry_id: None,
|
||||
segments: submitted,
|
||||
});
|
||||
for c in "draft while running".chars() {
|
||||
@@ -3477,6 +3483,7 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
let submitted = submit_text(&mut app, "normal run");
|
||||
app.handle_worker_event(Event::UserMessage {
|
||||
entry_id: None,
|
||||
segments: submitted,
|
||||
});
|
||||
app.handle_worker_event(Event::RunEnd { result });
|
||||
|
||||
@@ -1688,6 +1688,7 @@ mod tests {
|
||||
);
|
||||
|
||||
connection.observe_worker_event(&Event::UserMessage {
|
||||
entry_id: None,
|
||||
segments: vec![Segment::text("inspect"), Segment::UploadedFile { file }],
|
||||
});
|
||||
assert!(connection.pending_attachments.is_empty());
|
||||
@@ -1779,7 +1780,10 @@ mod tests {
|
||||
assert!(!reconcile_attachment_submission(
|
||||
&mut pending,
|
||||
&mut awaiting,
|
||||
&Event::UserMessage { segments },
|
||||
&Event::UserMessage {
|
||||
entry_id: None,
|
||||
segments,
|
||||
},
|
||||
));
|
||||
assert_eq!(awaiting, expected);
|
||||
assert!(pending.is_empty());
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError};
|
||||
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.
|
||||
pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024;
|
||||
/// 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.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) => {
|
||||
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)
|
||||
&& 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) => {
|
||||
result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& result
|
||||
@@ -482,8 +497,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
CommandRequest, EditRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath,
|
||||
WriteRequest,
|
||||
CommandRequest, EditRequest, GrepResult, ListRequest, ReadBytesRequest, ReadBytesResult,
|
||||
ReadRequest, Workdir, WorkdirPath, WriteRequest,
|
||||
};
|
||||
|
||||
fn assert_no_provider_authority(value: &Value) {
|
||||
@@ -614,7 +629,7 @@ mod tests {
|
||||
#[test]
|
||||
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
|
||||
let future = r#"{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
|
||||
}"#;
|
||||
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]
|
||||
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
|
||||
let unknown = r#"{
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"message": {
|
||||
"kind": "heartbeat",
|
||||
"generation": 1,
|
||||
|
||||
@@ -11,9 +11,9 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult,
|
||||
WorkdirError, WorkdirId, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest,
|
||||
WorkdirSession, WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
||||
@@ -66,6 +66,7 @@ pub enum WorkdirSessionOperation {
|
||||
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
||||
Stat(StatRequest),
|
||||
Read(ReadRequest),
|
||||
ReadBytes(ReadBytesRequest),
|
||||
Write(WriteRequest),
|
||||
Edit(EditRequest),
|
||||
List(ListRequest),
|
||||
@@ -97,6 +98,7 @@ pub enum WorkdirSessionOperationResult {
|
||||
ScopeRulesOverlap { overlaps: bool },
|
||||
Stat(StatResult),
|
||||
Read(ReadResult),
|
||||
ReadBytes(ReadBytesResult),
|
||||
Write(WriteResult),
|
||||
Edit(EditResult),
|
||||
List(ListResult),
|
||||
@@ -135,6 +137,10 @@ pub async fn dispatch_workdir_session_operation(
|
||||
.read(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::Read),
|
||||
WorkdirSessionOperation::ReadBytes(request) => session
|
||||
.read_bytes(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::ReadBytes),
|
||||
WorkdirSessionOperation::Write(request) => session
|
||||
.write(request)
|
||||
.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> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::Write(request))
|
||||
|
||||
@@ -27,7 +27,8 @@ use tokio::sync::broadcast;
|
||||
pub use fs_operation::{
|
||||
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
|
||||
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 local::{
|
||||
@@ -220,6 +221,14 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, 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 edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError>;
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError>;
|
||||
|
||||
@@ -30,10 +30,10 @@ use crate::{
|
||||
BoundedReadLimits, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest,
|
||||
CommandRequest, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
|
||||
WriteResult,
|
||||
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
|
||||
WorkdirError, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest,
|
||||
WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
|
||||
WorkdirToolScopePermission, WriteRequest, WriteResult,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::{EntryKind, WriteOutcome};
|
||||
@@ -1261,6 +1261,21 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
.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> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Write)?;
|
||||
let logical = request.path.clone();
|
||||
|
||||
@@ -16,9 +16,10 @@ use tokio::sync::broadcast;
|
||||
use crate::{
|
||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||
CommandSnapshot, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
|
||||
GrepResult, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
|
||||
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
GrepResult, ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirScopeAuthorizationRequest,
|
||||
WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle,
|
||||
WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
/// Stable Worker-local routing key for one attached Workdir.
|
||||
@@ -257,6 +258,11 @@ impl WorkdirSession for RoutedWorkdirSession {
|
||||
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> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.write(request).await
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::sync::{Arc, Mutex, Weak};
|
||||
use async_trait::async_trait;
|
||||
use fs_operation::{
|
||||
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 schemars::JsonSchema;
|
||||
@@ -1011,6 +1012,21 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
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> {
|
||||
let _scope_guard = self.scope_lock.lock().await;
|
||||
let path = self
|
||||
@@ -1276,6 +1292,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
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> {
|
||||
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 {
|
||||
WriteRequest {
|
||||
path: fs_path(path),
|
||||
@@ -1887,6 +1916,9 @@ mod tests {
|
||||
child.read(read("readme.md")).await.unwrap().bytes,
|
||||
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!(
|
||||
child.write(write("new.md", "no")).await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
|
||||
@@ -477,6 +477,13 @@ pub struct WorkerSessionObservationRequest {
|
||||
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 {
|
||||
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;
|
||||
|
||||
/// Read-only restore validation performed before any live controller,
|
||||
@@ -726,6 +743,13 @@ impl WorkerExecutionBackendRef {
|
||||
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(
|
||||
&self,
|
||||
request: WorkerExecutionSpawnRequest,
|
||||
|
||||
@@ -260,6 +260,35 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||
.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(
|
||||
&self,
|
||||
value: runtime_api::CreateWorkerRequest,
|
||||
|
||||
@@ -16,7 +16,8 @@ use crate::execution::{
|
||||
WorkerExecutionBackend, WorkerExecutionBackendRef, WorkerExecutionHandle,
|
||||
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionSpawnRequest,
|
||||
WorkerExecutionSpawnResult, WorkerExecutionStopRequest, WorkerLifecycleOperationId,
|
||||
WorkerSessionObservationRequest, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult,
|
||||
WorkerSessionHistoryRequest, WorkerSessionObservationRequest, WorkspaceConfigFetchRequest,
|
||||
WorkspaceConfigFetchResult,
|
||||
};
|
||||
#[cfg(feature = "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.
|
||||
pub fn restore_worker_scoped(
|
||||
&self,
|
||||
|
||||
@@ -172,6 +172,15 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
|
||||
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(
|
||||
&self,
|
||||
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(
|
||||
&self,
|
||||
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(
|
||||
&self,
|
||||
projection: worker::WorkspacePromptProjection,
|
||||
|
||||
@@ -18,8 +18,8 @@ use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
|
||||
use workdir::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
|
||||
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
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> {
|
||||
match self.operate(WorkdirSessionOperation::Write(request))? {
|
||||
WorkdirSessionOperationResult::Write(result) => Ok(result),
|
||||
|
||||
@@ -619,8 +619,8 @@ mod tests {
|
||||
let (committed_tx, committed_rx) = mpsc::channel();
|
||||
let commit_thread = thread::spawn(move || {
|
||||
// This mirrors Worker::append_entry ordering: clear in-flight first,
|
||||
// then publish the finalized AssistantItem. AssistantItem entries
|
||||
// are mirror-only and are not delivered as live entry events.
|
||||
// then publish the finalized AssistantItem. Identity-aware clients
|
||||
// reconcile this durable commit with the preceding streamed block.
|
||||
in_flight_for_commit.clear_for_committed_item_then(&assistant_item, || {
|
||||
sink_for_commit.publish(assistant_entry);
|
||||
});
|
||||
@@ -639,7 +639,10 @@ mod tests {
|
||||
in_flight_snapshot.blocks.as_slice(),
|
||||
[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();
|
||||
assert!(snapshot_from_guard(&post_commit_guard).is_empty());
|
||||
}
|
||||
|
||||
@@ -34,7 +34,41 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
||||
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
|
||||
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, .. } => {
|
||||
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
||||
Some(Event::SystemItem { item: value })
|
||||
@@ -94,7 +128,9 @@ mod tests {
|
||||
.expect("UserInput must be live-relevant");
|
||||
|
||||
match event {
|
||||
Event::UserMessage { segments: echoed } => assert_eq!(echoed, segments),
|
||||
Event::UserMessage {
|
||||
segments: echoed, ..
|
||||
} => assert_eq!(echoed, segments),
|
||||
other => panic!("expected UserMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,14 +92,11 @@ impl SegmentLogSink {
|
||||
/// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire.
|
||||
/// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`.
|
||||
/// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`.
|
||||
/// - assistant/tool commits → `Event::SessionEntryCommitted`.
|
||||
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
|
||||
/// Everything else (AssistantItem, ToolResult, TurnEnd,
|
||||
/// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension,
|
||||
/// ConfigChanged) is reflected in the mirror so reconnect snapshots stay accurate,
|
||||
/// 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.
|
||||
/// Operational checkpoints remain mirror-only. Assistant/tool streaming
|
||||
/// events are still the in-flight authority; the committed-entry event adds
|
||||
/// the durable stable identity needed to reconcile retained history.
|
||||
pub fn publish(&self, entry: LogEntry) {
|
||||
let mut mirror = self
|
||||
.inner
|
||||
@@ -122,6 +119,8 @@ impl SegmentLogSink {
|
||||
entry,
|
||||
LogEntry::AnnotatedSegmentStart { .. }
|
||||
| LogEntry::AnnotatedUserInput { .. }
|
||||
| LogEntry::AnnotatedAssistantItem { .. }
|
||||
| LogEntry::AnnotatedToolResult { .. }
|
||||
| LogEntry::AnnotatedSystemItem { .. }
|
||||
| LogEntry::Invoke { .. }
|
||||
)
|
||||
|
||||
@@ -50,7 +50,10 @@ zeroize.workspace = true
|
||||
webauthn-rs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
agen.workspace = true
|
||||
tempfile.workspace = true
|
||||
tools.workspace = true
|
||||
yoi = { path = "../yoi" }
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
||||
|
||||
@@ -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(
|
||||
&self,
|
||||
_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(
|
||||
&self,
|
||||
worker: &RuntimeWorkerRef,
|
||||
@@ -2909,6 +2947,19 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
.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(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
@@ -4924,6 +4975,27 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
.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(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
pub mod workdir_share;
|
||||
+31
-42
@@ -4,10 +4,11 @@ mod objective_cli;
|
||||
mod plugin_cli;
|
||||
mod session_cli;
|
||||
mod ticket_cli;
|
||||
mod workdir_share;
|
||||
mod worker_cleanup_cli;
|
||||
mod workspace_bootstrap;
|
||||
|
||||
use yoi::workdir_share;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
@@ -623,7 +624,7 @@ fn parse_workdir_args(
|
||||
let mut ttl = None;
|
||||
let mut read_only = 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 index = 0;
|
||||
while index < args.len() {
|
||||
@@ -668,29 +669,22 @@ fn parse_workdir_args(
|
||||
index += 2;
|
||||
}
|
||||
"--permission" => {
|
||||
let category = required_option_value(args, index, "--permission")?;
|
||||
let permissions =
|
||||
explicit_permissions.get_or_insert(server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: false,
|
||||
});
|
||||
let enabled = match category.to_ascii_lowercase().as_str() {
|
||||
"read" => &mut permissions.read,
|
||||
"write" => &mut permissions.write,
|
||||
"command" => &mut permissions.command,
|
||||
let level = required_option_value(args, index, "--permission")?;
|
||||
if explicit_permission_level.is_some() {
|
||||
return Err(ParseError(
|
||||
"--permission selects one level and may only be provided once".to_string(),
|
||||
));
|
||||
}
|
||||
explicit_permission_level = Some(match level.to_ascii_lowercase().as_str() {
|
||||
"read" => server_api::ExternalWorkdirPermissionLevel::Read,
|
||||
"write" => server_api::ExternalWorkdirPermissionLevel::Write,
|
||||
"command" => server_api::ExternalWorkdirPermissionLevel::Command,
|
||||
_ => {
|
||||
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;
|
||||
}
|
||||
"--read-only" => {
|
||||
@@ -742,21 +736,18 @@ fn parse_workdir_args(
|
||||
"--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(
|
||||
"--permission conflicts with legacy --read-only/--read-write".to_string(),
|
||||
));
|
||||
}
|
||||
let permissions = explicit_permissions.unwrap_or(server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: read_write,
|
||||
command: false,
|
||||
let permissions = explicit_permission_level
|
||||
.map(server_api::ExternalWorkdirPermissionLevel::permissions)
|
||||
.unwrap_or(if read_write {
|
||||
server_api::ExternalWorkdirPermissions::WRITE
|
||||
} else {
|
||||
server_api::ExternalWorkdirPermissions::READ
|
||||
});
|
||||
if permissions.write && !permissions.read {
|
||||
return Err(ParseError(
|
||||
"--permission write requires --permission read for read-before-write".to_string(),
|
||||
));
|
||||
}
|
||||
if non_interactive && workspace_id.is_none() {
|
||||
return Err(ParseError(
|
||||
"--non-interactive requires --workspace-id".to_string(),
|
||||
@@ -1900,7 +1891,7 @@ Usage:
|
||||
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>] 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]
|
||||
|
||||
Target selection:
|
||||
@@ -1946,7 +1937,7 @@ Options:
|
||||
|
||||
fn print_workdir_help() {
|
||||
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]
|
||||
fn workdir_share_parses_independent_command_permission() {
|
||||
fn workdir_share_parses_command_as_the_full_hierarchical_level() {
|
||||
let args = vec![
|
||||
"share".to_string(),
|
||||
".".to_string(),
|
||||
@@ -2056,11 +2047,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
options.permissions,
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: true,
|
||||
}
|
||||
server_api::ExternalWorkdirPermissions::COMMAND
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2131,17 +2118,19 @@ mod tests {
|
||||
.contains("conflicts")
|
||||
);
|
||||
|
||||
let write_without_read = vec![
|
||||
let multiple_levels = vec![
|
||||
"share".to_string(),
|
||||
".".to_string(),
|
||||
"--permission".to_string(),
|
||||
"write".to_string(),
|
||||
"--permission".to_string(),
|
||||
"command".to_string(),
|
||||
];
|
||||
assert!(
|
||||
parse_workdir_args(&write_without_read, &target)
|
||||
parse_workdir_args(&multiple_levels, &target)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("requires --permission read")
|
||||
.contains("selects one level")
|
||||
);
|
||||
|
||||
let missing_workspace = TargetSelection {
|
||||
|
||||
+156
-70
@@ -25,7 +25,7 @@ use workdir::{
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct WorkdirShareOptions {
|
||||
pub struct WorkdirShareOptions {
|
||||
pub path: PathBuf,
|
||||
pub workspace_id: Option<String>,
|
||||
pub backend_url: String,
|
||||
@@ -37,6 +37,39 @@ pub(crate) struct WorkdirShareOptions {
|
||||
|
||||
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>(®istered)
|
||||
.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 {
|
||||
if matches!(
|
||||
operation,
|
||||
@@ -49,7 +82,7 @@ fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProviderEnd {
|
||||
pub enum ProviderEnd {
|
||||
Disconnected,
|
||||
Revoked,
|
||||
Interrupted,
|
||||
@@ -140,47 +173,42 @@ fn authenticated_server_api_client(
|
||||
Ok(ServerApiClient::with_client(base_url, http_client))
|
||||
}
|
||||
|
||||
fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> String {
|
||||
let mut enabled = Vec::new();
|
||||
if permissions.read {
|
||||
enabled.push("READ");
|
||||
fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> &'static str {
|
||||
match permissions.grant_level() {
|
||||
Some(server_api::ExternalWorkdirPermissionLevel::Read) => "READ",
|
||||
Some(server_api::ExternalWorkdirPermissionLevel::Write) => "WRITE (includes READ)",
|
||||
Some(server_api::ExternalWorkdirPermissionLevel::Command) => {
|
||||
"COMMAND (includes WRITE + READ)"
|
||||
}
|
||||
if permissions.write {
|
||||
enabled.push("WRITE");
|
||||
None => "INVALID",
|
||||
}
|
||||
if permissions.command {
|
||||
enabled.push("COMMAND");
|
||||
}
|
||||
enabled.join(", ")
|
||||
}
|
||||
|
||||
fn validate_permissions(permissions: server_api::ExternalWorkdirPermissions) -> Result<(), String> {
|
||||
if permissions.write && !permissions.read {
|
||||
if permissions.grant_level().is_none() {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
if !permissions.read && !permissions.command {
|
||||
return Err("select at least one of READ or COMMAND".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workdir_capabilities(
|
||||
permissions: server_api::ExternalWorkdirPermissions,
|
||||
) -> workdir::WorkdirSessionCapabilities {
|
||||
let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY;
|
||||
if permissions.read {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY);
|
||||
match permissions.grant_level() {
|
||||
Some(server_api::ExternalWorkdirPermissionLevel::Read) => {
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
}
|
||||
if permissions.write {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY);
|
||||
Some(server_api::ExternalWorkdirPermissionLevel::Write) => {
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
}
|
||||
if permissions.command {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY);
|
||||
Some(server_api::ExternalWorkdirPermissionLevel::Command) => {
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
}
|
||||
None => workdir::WorkdirSessionCapabilities::EMPTY,
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -193,7 +221,7 @@ struct ResolvedWorkdirShareOptions {
|
||||
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') {
|
||||
(value, 60 * 60)
|
||||
} else if let Some(value) = value.strip_suffix('m') {
|
||||
@@ -302,14 +330,14 @@ fn configure_interactively(
|
||||
|
||||
writeln!(
|
||||
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}"))?;
|
||||
let Some(permission_input) = read_prompt_line(
|
||||
input,
|
||||
output,
|
||||
&format!(
|
||||
"Permissions [comma-separated READ/WRITE/COMMAND; current {}; q to cancel]: ",
|
||||
"Permission level [READ/WRITE/COMMAND; current {}; q to cancel]: ",
|
||||
permissions_label(options.permissions)
|
||||
),
|
||||
)?
|
||||
@@ -320,28 +348,20 @@ fn configure_interactively(
|
||||
return Ok(None);
|
||||
}
|
||||
if !permission_input.is_empty() {
|
||||
let mut permissions = server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
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,
|
||||
options.permissions = match permission_input.to_ascii_lowercase().as_str() {
|
||||
"r" | "read" | "read-only" | "read_only" => {
|
||||
server_api::ExternalWorkdirPermissions::READ
|
||||
}
|
||||
"w" | "write" | "read-write" | "read_write" => {
|
||||
server_api::ExternalWorkdirPermissions::WRITE
|
||||
}
|
||||
"c" | "command" => server_api::ExternalWorkdirPermissions::COMMAND,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"unknown permission category `{category}`; use READ, WRITE, or COMMAND"
|
||||
"unknown permission level `{permission_input}`; use READ, WRITE, or COMMAND"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
validate_permissions(permissions)?;
|
||||
options.permissions = permissions;
|
||||
};
|
||||
}
|
||||
|
||||
writeln!(output, "TTL: {}", ttl_label(options.ttl))
|
||||
@@ -437,7 +457,7 @@ async fn resolve_share_options(
|
||||
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 {
|
||||
eprintln!("External Workdir share cancelled; no grant was created.");
|
||||
return Ok(());
|
||||
@@ -649,7 +669,7 @@ async fn serve_provider_connection(
|
||||
.max_message_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 (mut socket, _) = tokio::select! {
|
||||
let (socket, _) = tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||
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,
|
||||
},
|
||||
});
|
||||
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, ®istration).await?;
|
||||
let registered = tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
|
||||
signal = &mut interrupt => {
|
||||
signal?;
|
||||
return Ok(ProviderEnd::Interrupted);
|
||||
}
|
||||
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}"))?
|
||||
}
|
||||
};
|
||||
let Message::Text(registered) = registered else {
|
||||
return Err("Backend returned an invalid External Workdir registration frame".to_string());
|
||||
};
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(®istered)
|
||||
.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());
|
||||
}
|
||||
validate_provider_registration(registered, generation)?;
|
||||
println!("Connection: online (generation {generation})");
|
||||
|
||||
let (completion_sender, mut completions) =
|
||||
@@ -704,10 +742,9 @@ async fn serve_provider_connection(
|
||||
let mut operations = HashMap::<String, OperationTask>::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||
signal = &mut interrupt => {
|
||||
signal?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
|
||||
return Ok(ProviderEnd::Interrupted);
|
||||
}
|
||||
completion = completions.recv() => {
|
||||
@@ -916,7 +953,7 @@ async fn execute_operation(
|
||||
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
|
||||
Err(message) => ExternalWorkdirOperationOutcome::Failed {
|
||||
error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||
code: workdir::http::WorkdirTransportErrorCode::Unsupported,
|
||||
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
|
||||
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]
|
||||
fn interactive_share_defaults_to_first_workspace_read_only_and_unlimited() {
|
||||
let workspaces = [workspace("workspace-a", "Alpha")];
|
||||
@@ -1085,12 +1139,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_share_requires_explicit_write_and_command_selection_and_confirmation() {
|
||||
fn interactive_share_selects_one_hierarchical_level_and_requires_confirmation() {
|
||||
let workspaces = [
|
||||
workspace("workspace-a", "Alpha"),
|
||||
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 selected =
|
||||
@@ -1110,7 +1164,39 @@ mod tests {
|
||||
assert_eq!(selected.ttl, Some(Duration::from_secs(900)));
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
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]
|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
# External Workdir sharing permissions
|
||||
|
||||
`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,
|
||||
ViewImage, Glob, and Grep.
|
||||
- **WRITE**: Write and Edit. WRITE requires READ because updating an existing
|
||||
file uses the read-before-write content-hash contract. Selecting WRITE never
|
||||
enables COMMAND.
|
||||
- **COMMAND**: command start, status, bounded output, and cancellation. COMMAND
|
||||
does not enable READ or WRITE. It executes on the sharing host with the OS
|
||||
- **READ** (capability bits `25`): file reads and read-only discovery operations
|
||||
such as Stat, List, ViewImage, Glob, and Grep.
|
||||
- **WRITE** (capability bits `31`): READ plus Write and Edit. Including READ
|
||||
preserves the read-before-write content-hash contract. WRITE never enables
|
||||
COMMAND.
|
||||
- **COMMAND** (capability bits `63`): WRITE plus READ plus command start, status,
|
||||
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
|
||||
**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
|
||||
asks for a comma-separated category list and shows the exact grant before it is
|
||||
created. For automation, repeat `--permission`:
|
||||
In short, `COMMAND ⇒ WRITE ⇒ READ`. READ is the default. Interactive mode asks
|
||||
for one level and shows its included permissions before creating the grant. For
|
||||
automation, pass `--permission` once:
|
||||
|
||||
```sh
|
||||
yoi --backend https://example.invalid workdir share ./project \
|
||||
--workspace-id workspace-a \
|
||||
--permission read \
|
||||
--permission write \
|
||||
--permission command \
|
||||
--non-interactive
|
||||
```
|
||||
|
||||
`--read-only` remains an alias for READ. `--read-write` remains an alias for
|
||||
READ+WRITE and never enables COMMAND. These legacy flags conflict with explicit
|
||||
`--permission` arguments so ambiguous automation fails closed.
|
||||
`--read-only` remains an alias for the READ level. `--read-write` remains an
|
||||
alias for the WRITE level and never enables COMMAND. These legacy flags conflict
|
||||
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
|
||||
60 seconds and 24 hours. Ctrl-C, revoke, expiry, provider disconnect, attachment
|
||||
|
||||
@@ -2092,15 +2092,15 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
@@ -14020,7 +14020,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c"
|
||||
"x-yoi-source-digest": "sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"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;
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
"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",
|
||||
"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": {
|
||||
"lineWidth": 100,
|
||||
|
||||
@@ -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/lib/workspace/header/`: Header frame, context, and overrides.
|
||||
- `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/routes/design-lab/workspace-web-ux/`: static Design Language showroom.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"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",
|
||||
"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",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// 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
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// 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
@@ -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, });
|
||||
|
||||
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,
|
||||
/**
|
||||
* 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 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.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// 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
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// 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.
|
||||
// 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
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// 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.
|
||||
// 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
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// 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.
|
||||
// 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
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// 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.
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -182,7 +182,9 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
||||
);
|
||||
const kind = stringField(source, "kind");
|
||||
if (kind === "repository") {
|
||||
if (source.grant_id !== undefined || source.grant_permissions !== undefined) {
|
||||
if (
|
||||
source.grant_id !== undefined || source.grant_permissions !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"Repository Workdir source must not contain External grant authority",
|
||||
);
|
||||
@@ -201,7 +203,7 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
||||
return {
|
||||
kind,
|
||||
grant_id: stringField(source, "grant_id"),
|
||||
grant_permissions: parseExternalWorkdirPermissions(
|
||||
grant_permissions: parseExternalWorkdirGrantPermissions(
|
||||
source.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 {
|
||||
const record = exactRecord(
|
||||
value,
|
||||
|
||||
@@ -106,7 +106,7 @@ Deno.test("Worker summary parser preserves attachment-effective External permiss
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
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",
|
||||
status: "active",
|
||||
|
||||
@@ -4,15 +4,15 @@ import { cleanup, fireEvent, render } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import ConsoleTurnNavigation from "./ConsoleTurnNavigation.svelte";
|
||||
|
||||
const turns = [
|
||||
{ id: "u1", user: "First question", assistant: "First answer" },
|
||||
{ id: "u2", user: "Second question", assistant: "" },
|
||||
const items = [
|
||||
{ turnId: "u1", lineId: "u1", user: "First question", assistant: ["First answer"] },
|
||||
{ turnId: "u2", lineId: "u2", user: "Second question", assistant: [] },
|
||||
];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
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.queryByRole("tooltip")).toBeNull();
|
||||
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();
|
||||
});
|
||||
|
||||
test("focus previews unanswered turns, click selects the user anchor, Escape dismisses", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const view = render(ConsoleTurnNavigation, { turns, onSelect });
|
||||
test("focus previews unanswered items, click selects the user anchor, Escape dismisses", async () => {
|
||||
const onTurnClick = vi.fn();
|
||||
const view = render(ConsoleTurnNavigation, { items, onTurnClick });
|
||||
const button = view.getAllByRole("button")[1];
|
||||
await fireEvent.focus(button);
|
||||
expect(view.getByRole("tooltip").textContent).toContain("No response yet");
|
||||
await fireEvent.click(button);
|
||||
expect(onSelect).toHaveBeenCalledWith("u2");
|
||||
expect(onTurnClick).toHaveBeenCalledWith(items[1]);
|
||||
await fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(view.queryByRole("tooltip")).toBeNull();
|
||||
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 () => {
|
||||
const onSelect = vi.fn();
|
||||
const view = render(ConsoleTurnNavigation, { turns, onSelect });
|
||||
const onTurnClick = vi.fn();
|
||||
const view = render(ConsoleTurnNavigation, { items, onTurnClick });
|
||||
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.getAllByRole("button")).toHaveLength(2);
|
||||
await view.rerender({ turns: [], onSelect });
|
||||
await view.rerender({ items: [], onTurnClick });
|
||||
expect(view.queryByRole("tooltip")).toBeNull();
|
||||
expect(view.queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("preview content stays text and scrolling the rail dismisses its old position", async () => {
|
||||
const view = render(ConsoleTurnNavigation, {
|
||||
turns: [{ id: "u", user: "<img src=x onerror=alert(1)>", assistant: "<script>bad()</script>" }],
|
||||
onSelect: vi.fn(),
|
||||
items: [{ turnId: "u", lineId: "u", user: "<img src=x onerror=alert(1)>", assistant: ["<script>bad()</script>"] }],
|
||||
onTurnClick: vi.fn(),
|
||||
});
|
||||
await fireEvent.mouseEnter(view.getByRole("button"));
|
||||
const tooltip = view.getByRole("tooltip");
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { ConsoleTurn } from './turn-navigation';
|
||||
import type { TurnNavigationItem } from './turn-navigation';
|
||||
|
||||
let { turns, onSelect }: {
|
||||
turns: ConsoleTurn[];
|
||||
onSelect: (id: string) => void;
|
||||
let {
|
||||
items, onTurnClick, hasMore = false, loading = false, error = null,
|
||||
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();
|
||||
|
||||
const tooltipId = $props.id();
|
||||
@@ -14,8 +25,8 @@
|
||||
let railHeight = $state(0);
|
||||
let previewHeight = $state(0);
|
||||
let rail: HTMLElement;
|
||||
const preview = $derived(dismissed ? undefined : turns.find(
|
||||
(turn) => turn.id === (hoveredId ?? focusedId),
|
||||
const preview = $derived(dismissed ? undefined : items.find(
|
||||
(turn) => turn.turnId === (hoveredId ?? focusedId),
|
||||
));
|
||||
const previewTop = $derived(Math.max(0, Math.min(anchorY, railHeight - previewHeight)));
|
||||
|
||||
@@ -37,18 +48,35 @@
|
||||
bind:clientHeight={railHeight}
|
||||
onmouseleave={() => { hoveredId = null; }}
|
||||
>
|
||||
<div class="turn-bars" onscroll={() => { dismissed = true; }}>
|
||||
{#each turns as turn, index (turn.id)}
|
||||
<div class="turn-bars turn-list" bind:this={element} onscroll={(event) => {
|
||||
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
|
||||
type="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-describedby={preview?.id === turn.id ? tooltipId : undefined}
|
||||
onmouseenter={(event) => show(event, turn.id)}
|
||||
onfocus={(event) => show(event, turn.id)}
|
||||
aria-describedby={preview?.turnId === turn.turnId ? tooltipId : undefined}
|
||||
onmouseenter={(event) => show(event, turn.turnId)}
|
||||
onfocus={(event) => show(event, turn.turnId)}
|
||||
onblur={() => { focusedId = null; }}
|
||||
onclick={() => onSelect(turn.id)}
|
||||
onclick={() => onTurnClick(turn)}
|
||||
><span class="turn-bar" aria-hidden="true"></span></button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -60,7 +88,7 @@
|
||||
>
|
||||
<div class="turn-preview" id={tooltipId} role="tooltip">
|
||||
<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>
|
||||
{/if}
|
||||
@@ -86,7 +114,19 @@
|
||||
.turn-bars::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* 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 {
|
||||
|
||||
@@ -20,13 +20,9 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import SvelteMarkdown, {
|
||||
buildUnsupportedHTML,
|
||||
type Renderers,
|
||||
type SvelteMarkdownProps,
|
||||
} from "@humanspeak/svelte-markdown";
|
||||
import type { Renderers } from "@humanspeak/svelte-markdown";
|
||||
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 = {
|
||||
text: string;
|
||||
@@ -36,24 +32,15 @@
|
||||
|
||||
let { text, streamId = "static", class: className = "" }: Props = $props();
|
||||
|
||||
const options = {
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
|
||||
|
||||
const renderers = {
|
||||
code: ShikiCode,
|
||||
html: buildUnsupportedHTML(),
|
||||
link: MarkdownLink,
|
||||
} satisfies Partial<Renderers>;
|
||||
</script>
|
||||
|
||||
<div class={`rich-markdown ${className}`}>
|
||||
<SvelteMarkdown source={text} {streamId} {options} {renderers} streaming />
|
||||
</div>
|
||||
<SafeMarkdown {text} {streamId} class={`rich-markdown ${className}`} {renderers} streaming />
|
||||
|
||||
<style>
|
||||
.rich-markdown {
|
||||
:global(.rich-markdown) {
|
||||
color: inherit;
|
||||
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,
|
||||
createConsoleProjector,
|
||||
isConsoleProjectionEvent,
|
||||
mergeCommittedHistoryLines,
|
||||
projectConsole,
|
||||
projectConsoleLines,
|
||||
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");
|
||||
});
|
||||
|
||||
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,
|
||||
InternalWorkerSnapshot,
|
||||
Segment,
|
||||
SessionSnapshotEntry,
|
||||
WorkerState,
|
||||
WorkerStateSnapshot,
|
||||
WorkerStatus,
|
||||
@@ -90,6 +91,8 @@ export type ConsoleLine = {
|
||||
compaction?: ConsoleCompaction;
|
||||
diff?: ConsoleDiffLine[];
|
||||
eventId?: string | null;
|
||||
/** Stable durable identity when this line originates from a committed entry. */
|
||||
entryId?: string;
|
||||
source: "event";
|
||||
streaming?: 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(
|
||||
projection: ConsoleProjection,
|
||||
): ConsoleProjection {
|
||||
@@ -813,14 +896,19 @@ export function applyProtocolEvent(
|
||||
|
||||
switch (event.event) {
|
||||
case "user_message":
|
||||
next.lines.push(
|
||||
line(
|
||||
next.lines.push({
|
||||
...line(
|
||||
envelope.eventId,
|
||||
"user",
|
||||
"User",
|
||||
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;
|
||||
case "system_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",
|
||||
detail: calls.map(readDetail).join("\n\n"),
|
||||
eventId: group.at(-1)?.eventId,
|
||||
entryId: group.at(-1)?.entryId,
|
||||
source: "event",
|
||||
streaming: inProgress,
|
||||
error: hasError,
|
||||
@@ -1971,6 +2060,22 @@ function snapshotProjectionFromSession(
|
||||
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(
|
||||
projection: ConsoleProjection,
|
||||
fallbackEventId: string,
|
||||
@@ -1978,6 +2083,7 @@ function applySessionEntry(
|
||||
): void {
|
||||
if (!isRecord(value)) return;
|
||||
const eventId = stringField(value, "entry_id") ?? fallbackEventId;
|
||||
const lineCountBefore = projection.lines.length;
|
||||
switch (stringField(value, "kind")) {
|
||||
case "user_input":
|
||||
projection.lines.push(
|
||||
@@ -2035,6 +2141,18 @@ function applySessionEntry(
|
||||
default:
|
||||
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(
|
||||
|
||||
@@ -34,3 +34,14 @@ export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] {
|
||||
}
|
||||
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(
|
||||
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
memoryDocumentLoad.includes('"/memory"') &&
|
||||
memoryDocumentPage.includes("Memory Document") &&
|
||||
memoryDocumentPage.includes("This view is read-only") &&
|
||||
memoryDocumentPage.includes("DocumentMarkdown from") &&
|
||||
memoryDocumentPage.includes("Read-only") &&
|
||||
memoryDocumentPage.includes("data.memory.data.body_md") &&
|
||||
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("data.memory.data.updated_at") &&
|
||||
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(
|
||||
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));
|
||||
assert(
|
||||
!/timeline/i.test(consolePage) &&
|
||||
consolePage.includes("const turns = $derived(consoleTurns(lines))") &&
|
||||
consolePage.includes("<ConsoleTurnNavigation {turns} onSelect={selectConsoleTurn}") &&
|
||||
consolePage.includes("conversationTurnPreviewsFromLines(lines)") &&
|
||||
consolePage.includes("items={turnNavigationItems}") &&
|
||||
consolePage.includes("CSS.escape(id)") &&
|
||||
consolePage.includes("scrollbar-width: none") &&
|
||||
consolePage.includes("::-webkit-scrollbar"),
|
||||
"Console should replace the scrollbar with conversation turn navigation, not the former timeline",
|
||||
);
|
||||
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("onmouseenter=") &&
|
||||
navigation.includes("onfocus=") &&
|
||||
navigation.includes("onclick={() => onSelect(turn.id)}") &&
|
||||
navigation.includes("onclick={() => onTurnClick(turn)}") &&
|
||||
navigation.includes('role="tooltip"') &&
|
||||
navigation.includes("aria-describedby=") &&
|
||||
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";
|
||||
|
||||
/** Render independent Workdir authority categories in a stable, non-conflating order. */
|
||||
/** Render an effective attachment/session capability intersection without widening it. */
|
||||
export function formatWorkdirPermissions(
|
||||
permissions: ExternalWorkdirPermissions,
|
||||
): string {
|
||||
@@ -10,3 +10,21 @@ export function formatWorkdirPermissions(
|
||||
if (permissions.command) categories.push("COMMAND");
|
||||
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">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate } from '$lib/workspace/api/http';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const lineCount = $derived(data.memory.data?.body_md.split('\n').length ?? 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Memory Document · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory Document" />
|
||||
<title>Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory document" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="card memory-document-card">
|
||||
<div class="detail-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace memory</p>
|
||||
<h2>Memory Document</h2>
|
||||
</div>
|
||||
<section class="memory-document-page" aria-label="Memory document">
|
||||
{#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.
|
||||
<div class="memory-document-meta">
|
||||
<p>
|
||||
<span>Read-only</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Updated <time datetime={data.memory.data.updated_at}>{formatDate(data.memory.data.updated_at)}</time></span>
|
||||
</p>
|
||||
|
||||
{#if data.memory.data}
|
||||
<div class="memory-document-summary" aria-label="Memory document summary">
|
||||
<details>
|
||||
<summary>Document details</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<span>Updated</span>
|
||||
<strong>{formatDate(data.memory.data.updated_at)}</strong>
|
||||
<dt>Created</dt>
|
||||
<dd><time datetime={data.memory.data.created_at}>{formatDate(data.memory.data.created_at)}</time></dd>
|
||||
</div>
|
||||
<div>
|
||||
<span>Created</span>
|
||||
<strong>{formatDate(data.memory.data.created_at)}</strong>
|
||||
<dt>Size</dt>
|
||||
<dd>{data.memory.data.bytes} bytes</dd>
|
||||
</div>
|
||||
<div>
|
||||
<span>Lines</span>
|
||||
<strong>{lineCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Source</span>
|
||||
<strong>{data.memory.data.record_source}</strong>
|
||||
<dt>Source</dt>
|
||||
<dd><code>{data.memory.data.record_source}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
<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}
|
||||
{: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}
|
||||
<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}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-document-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-document-summary {
|
||||
.memory-document-page {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 78rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-document-summary div {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bg-raised);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.memory-document-summary span {
|
||||
.memory-document-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.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);
|
||||
display: block;
|
||||
font-size: var(--font-size-compact);
|
||||
margin-top: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-document-body {
|
||||
background: var(--bg-raised);
|
||||
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;
|
||||
.memory-document-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.memory-document-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.memory-document-state {
|
||||
padding-block: var(--space-5);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.memory-document-summary {
|
||||
grid-template-columns: 1fr;
|
||||
.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);
|
||||
}
|
||||
|
||||
.memory-document-meta details {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</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 ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.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 ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
|
||||
import { namePastedImage } from "$lib/workspace/console/composer-paste";
|
||||
@@ -30,7 +29,9 @@
|
||||
consoleWorkerViews,
|
||||
createConsoleProjector,
|
||||
isConsoleProjectionEvent,
|
||||
mergeCommittedHistoryLines,
|
||||
projectConsoleLines,
|
||||
projectSessionHistoryEntries,
|
||||
resolveConsoleViewScrollTop,
|
||||
resolveConsoleWorkerView,
|
||||
type ConsoleEventInput,
|
||||
@@ -44,6 +45,7 @@
|
||||
PendingSubmissionsSnapshot,
|
||||
RewindTarget,
|
||||
Segment,
|
||||
SessionHistoryPage,
|
||||
} from "$lib/generated/protocol";
|
||||
import {
|
||||
MAX_FILES_PER_SUBMISSION,
|
||||
@@ -69,6 +71,18 @@
|
||||
type WorkerSessionRequestIdentity,
|
||||
type WorkerSessionTarget,
|
||||
} 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";
|
||||
|
||||
type Props = {
|
||||
@@ -162,6 +176,7 @@
|
||||
let workerViewSelectionGeneration = 0;
|
||||
let consoleViewMode = $state<ConsoleViewMode>("overview");
|
||||
let consoleBodyElement: HTMLElement | null = null;
|
||||
let turnNavigationElement = $state<HTMLElement | null>(null);
|
||||
let composerInputElement = $state<
|
||||
(SvelteComponent & ComposerInputHandle) | null
|
||||
>(null);
|
||||
@@ -176,6 +191,8 @@
|
||||
let consoleProjection = $state.raw<ConsoleProjection>(
|
||||
consoleProjector.snapshot(),
|
||||
);
|
||||
let historyByView = $state.raw<Record<string, ConsoleHistoryState>>({});
|
||||
const pendingHistoryRefreshes = new Set<string>();
|
||||
let pendingObservationEvents: ConsoleEventInput[] = [];
|
||||
let pendingInitialSnapshotApplication: {
|
||||
eventId: string;
|
||||
@@ -205,10 +222,30 @@
|
||||
),
|
||||
);
|
||||
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),
|
||||
);
|
||||
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 diagnostics = $derived(
|
||||
mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics),
|
||||
@@ -264,6 +301,233 @@
|
||||
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) {
|
||||
workerError = null;
|
||||
try {
|
||||
@@ -406,6 +670,22 @@
|
||||
liveWorkerState = consoleProjection.status === "shutdown"
|
||||
? "shutdown"
|
||||
: 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 (
|
||||
initialSnapshotApplication &&
|
||||
eventBatch.some(
|
||||
@@ -1338,6 +1618,7 @@
|
||||
}
|
||||
autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
|
||||
rememberConsoleWorkerViewScroll();
|
||||
handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1);
|
||||
}
|
||||
|
||||
async function scrollConsoleToBottom() {
|
||||
@@ -1556,6 +1837,24 @@
|
||||
<p class="error">{workerError}</p>
|
||||
{/if}
|
||||
|
||||
{#if selectedWorkerViewSessionId === null && (selectedHistory.hasMore || selectedHistory.error || selectedHistory.status === "loading")}
|
||||
<div class="conversation-history-boundary">
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
|
||||
<ConsoleDisplayStateView
|
||||
state={consoleDisplayState}
|
||||
hasContent={lines.length > 0}
|
||||
@@ -1572,11 +1871,20 @@
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && turns.length > 0}
|
||||
{#key `${workerId}:${selectedWorkerView.sessionId}`}
|
||||
<ConsoleTurnNavigation {turns} onSelect={selectConsoleTurn} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
|
||||
{#if taskPaneOpen}
|
||||
@@ -2041,6 +2349,36 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
+454
-14
@@ -6,10 +6,12 @@ import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
Event as ProtocolEvent,
|
||||
SessionConversationTurn,
|
||||
SessionSnapshot,
|
||||
} from "$lib/generated/protocol";
|
||||
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 {
|
||||
return {
|
||||
event: "snapshot",
|
||||
@@ -367,18 +426,30 @@ test("applies retained empty and nonempty snapshots before showing their read-on
|
||||
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.queryByText("No conversation to display")).toBeNull();
|
||||
});
|
||||
|
||||
test("shows bounded initial failures and retries explicitly", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
||||
.mockResolvedValueOnce(Response.json({
|
||||
let sessionRequests = 0;
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
if (String(input).includes("/session/history")) {
|
||||
return Promise.resolve(Response.json(emptyHistoryPage()));
|
||||
}
|
||||
sessionRequests += 1;
|
||||
return Promise.resolve(
|
||||
sessionRequests === 1
|
||||
? new Response(null, { status: 503 })
|
||||
: Response.json({
|
||||
availability: "retained_snapshot",
|
||||
snapshot: emptySession(),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
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" }));
|
||||
|
||||
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 () => {
|
||||
@@ -426,17 +497,165 @@ test("preserves rendered content while reconnecting", async () => {
|
||||
latestListener().onFrame(
|
||||
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");
|
||||
expect(await screen.findByText("Conversation updates are unavailable")).not
|
||||
.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();
|
||||
|
||||
latestListener().onStatus?.("connecting", "resubscribing");
|
||||
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 () => {
|
||||
@@ -444,6 +663,9 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
const current = deferred<Response>();
|
||||
let firstWorkerLoads = 0;
|
||||
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")) {
|
||||
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(
|
||||
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");
|
||||
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 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(
|
||||
"Loading conversation",
|
||||
);
|
||||
@@ -476,7 +704,12 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
availability: "retained_snapshot",
|
||||
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({
|
||||
availability: "unavailable",
|
||||
@@ -484,6 +717,213 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
}));
|
||||
await settleMicrotasks();
|
||||
expect(screen.queryByText("stale worker error")).toBeNull();
|
||||
expect(screen.queryByText("previous worker content")).toBeNull();
|
||||
expect(screen.getByText("current worker content")).not.toBeNull();
|
||||
expect(
|
||||
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,
|
||||
} from '$lib/workspace/api/workers';
|
||||
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 {
|
||||
buildCreateWorkspaceWorkerRequest,
|
||||
defaultWorkerLaunchForm,
|
||||
@@ -40,7 +40,7 @@
|
||||
: null;
|
||||
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
|
||||
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)}`;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
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",
|
||||
status: "active",
|
||||
@@ -72,7 +72,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
|
||||
}
|
||||
if (
|
||||
!detail.item.source.grant_permissions.read ||
|
||||
detail.item.source.grant_permissions.write ||
|
||||
!detail.item.source.grant_permissions.write ||
|
||||
!detail.item.source.grant_permissions.command
|
||||
) {
|
||||
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 (
|
||||
const source of [
|
||||
{ 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",
|
||||
grant_id: "grant-1",
|
||||
@@ -113,7 +118,11 @@ Deno.test("Workdir REST validation rejects missing or misplaced External grant p
|
||||
rejected = true;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
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 = [
|
||||
[{ read: true, write: false, command: false }, "READ"],
|
||||
[{ 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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user