diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs index 51f4cd7ea..465e3b6b9 100644 --- a/crates/fs-operation/src/lib.rs +++ b/crates/fs-operation/src/lib.rs @@ -154,6 +154,119 @@ pub fn open_beneath_no_symlinks(_root: &Path, _path: &Path) -> std::io::Result std::io::Result<()>, +) -> std::io::Result<()> { + use std::ffi::{CString, OsString}; + use std::io::Write; + use std::os::fd::{AsRawFd, FromRawFd, RawFd}; + use std::os::unix::ffi::OsStrExt; + + if relative.is_absolute() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "path is outside provider root", + )); + } + let mut components = relative.components().peekable(); + let mut parent = root.try_clone()?; + let mut file_name = None::; + while let Some(component) = components.next() { + let std::path::Component::Normal(name) = component else { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "path is outside provider root", + )); + }; + if components.peek().is_none() { + file_name = Some(name.to_os_string()); + break; + } + let name = CString::new(name.as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL") + })?; + // SAFETY: `name` is a valid C string and `parent` remains open. + let mut fd: RawFd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::NotFound { + return Err(error); + } + // SAFETY: same valid directory descriptor and component C string. + let created = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o755) }; + if created < 0 { + let create_error = std::io::Error::last_os_error(); + if create_error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(create_error); + } + } + // SAFETY: same as the first openat; a racing symlink is rejected by O_NOFOLLOW. + fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + } + // SAFETY: fd is a fresh descriptor returned by openat. + parent = unsafe { std::fs::File::from_raw_fd(fd) }; + } + let file_name = file_name.ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "file path is empty") + })?; + let parent_path = PathBuf::from(format!("/proc/self/fd/{}", parent.as_raw_fd())); + let mut temporary = tempfile::NamedTempFile::new_in(&parent_path)?; + temporary.write_all(content)?; + temporary.flush()?; + temporary.as_file().sync_all()?; + before_commit()?; + let destination = parent_path.join(file_name); + match mode { + AtomicWriteMode::CreateNew => temporary + .persist_noclobber(destination) + .map_err(|error| error.error)?, + AtomicWriteMode::Replace => temporary + .persist(destination) + .map_err(|error| error.error)?, + }; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn atomic_write_beneath_no_symlinks_at( + _root: &std::fs::File, + _relative: &Path, + _content: &[u8], + _mode: AtomicWriteMode, + _before_commit: impl FnOnce() -> std::io::Result<()>, +) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "root-confined no-symlink write is unavailable on this platform", + )) +} + /// Keeps a descriptor-backed traversal path alive for the duration of a walk. #[derive(Debug)] pub struct FsTraversalRoot { @@ -218,6 +331,47 @@ pub trait FsAccessPolicy: Send + Sync { std::fs::metadata(resolved) } + /// Bound provider-owned mutation preimages and results before allocation or + /// commit. `None` retains the provider's ordinary filesystem behavior. + fn max_write_bytes(&self) -> Option { + None + } + + /// Bound provider-owned Edit result cardinality before mutation. + fn max_edit_replacements(&self) -> Option { + None + } + + /// Atomically replace or create an already-authorized writable file. + /// Capability providers override this to keep parent traversal descriptor- + /// confined through the final rename. + fn atomic_write_file( + &self, + _logical: &Path, + resolved: &Path, + content: &[u8], + mode: AtomicWriteMode, + ) -> std::io::Result<()> { + use std::io::Write; + + let parent = resolved.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "file path has no parent") + })?; + std::fs::create_dir_all(parent)?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(content)?; + temporary.flush()?; + temporary.as_file().sync_all()?; + self.check_cancelled()?; + match mode { + AtomicWriteMode::CreateNew => temporary + .persist_noclobber(resolved) + .map_err(|error| error.error)?, + AtomicWriteMode::Replace => temporary.persist(resolved).map_err(|error| error.error)?, + }; + Ok(()) + } + /// Open an already-authorized directory for bounded enumeration. /// Capability providers override this to bind traversal to a confined /// directory descriptor rather than reopening a mutable path. @@ -372,6 +526,33 @@ mod tests { } } + struct CreateRaceAccess { + root: PathBuf, + destination: PathBuf, + } + + impl FsAccessPolicy for CreateRaceAccess { + fn is_readable(&self, path: &Path) -> bool { + path.starts_with(&self.root) + } + + fn is_writable(&self, path: &Path) -> bool { + path.starts_with(&self.root) + } + + fn read_metadata( + &self, + _logical: &Path, + _resolved: &Path, + ) -> std::io::Result { + std::fs::write(&self.destination, b"created concurrently")?; + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "simulated absence before create race", + )) + } + } + fn grep_request(path: &str, pattern: &str) -> GrepRequest { GrepRequest { pattern: pattern.to_string(), @@ -1026,6 +1207,82 @@ mod tests { )); } + #[test] + fn no_clobber_create_race_is_reported_as_conflict() { + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("race.txt"); + let root_path = root.path().canonicalize().unwrap(); + let access = CreateRaceAccess { + root: root_path.clone(), + destination: destination.clone(), + }; + + let error = run_write( + &root_path, + WriteRequest { + path: FsPath::new("race.txt").unwrap(), + content: b"worker content".to_vec(), + expected_hash: None, + }, + &access, + ) + .unwrap_err(); + + assert!(matches!(error, FsError::Conflict(path) if path == "race.txt")); + assert_eq!( + std::fs::read_to_string(destination).unwrap(), + "created concurrently" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn descriptor_confined_atomic_write_rejects_symlinks_and_survives_root_rename() { + use std::os::unix::fs::symlink; + + let parent = tempfile::tempdir().unwrap(); + let selected = parent.path().join("selected"); + std::fs::create_dir(&selected).unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), "secret").unwrap(); + symlink(outside.path(), selected.join("escape")).unwrap(); + let root = open_root_no_symlinks(&selected).unwrap(); + + let error = atomic_write_beneath_no_symlinks_at( + &root, + Path::new("escape/secret.txt"), + b"escaped", + AtomicWriteMode::Replace, + || Ok(()), + ) + .unwrap_err(); + assert!(matches!( + error.kind(), + std::io::ErrorKind::NotADirectory | std::io::ErrorKind::PermissionDenied + )); + assert_eq!( + std::fs::read_to_string(outside.path().join("secret.txt")).unwrap(), + "secret" + ); + + std::fs::rename(&selected, parent.path().join("approved-original")).unwrap(); + std::fs::create_dir(&selected).unwrap(); + atomic_write_beneath_no_symlinks_at( + &root, + Path::new("nested/created.txt"), + b"approved", + AtomicWriteMode::CreateNew, + || Ok(()), + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(parent.path().join("approved-original/nested/created.txt")) + .unwrap(), + "approved" + ); + assert!(!selected.join("nested/created.txt").exists()); + } + #[test] fn grep_content_groups_lines_by_file_and_marks_matches() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/fs-operation/src/local.rs b/crates/fs-operation/src/local.rs index 553271171..aaff61910 100644 --- a/crates/fs-operation/src/local.rs +++ b/crates/fs-operation/src/local.rs @@ -1,14 +1,14 @@ use std::ffi::OsString; use std::fs; -use std::io::{BufReader, Read, Write}; +use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; use crate::{ - BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsAccessPolicy, FsError, - FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, - WriteRequest, WriteResult, direct_symlink, + AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, + FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult, + StatRequest, StatResult, WriteRequest, WriteResult, direct_symlink, }; const READ_BUFFER_BYTES: usize = 16 * 1024; @@ -204,25 +204,40 @@ pub fn run_write( access .check_cancelled() .map_err(|error| map_io(&logical, error))?; + enforce_write_bound(&logical, request.content.len(), access)?; let path = resolve(root, &logical)?; - let created = !path.exists(); - if path.exists() { - let target = require_access(&path, &logical, access, true, false)?; - let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?; + let target = require_access(&path, &logical, access, true, false)?; + let metadata = match access.read_metadata(&path, &target) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(map_io(&logical, error)), + }; + let created = metadata.is_none(); + if let Some(metadata) = metadata { if metadata.is_dir() { return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); } - let actual = hash_bytes(&fs::read(&target).map_err(|error| map_io(&logical, error))?); + let bytes = read_mutation_preimage(&logical, &path, &target, metadata, access)?; + let actual = hash_bytes(&bytes); if request.expected_hash != Some(actual) { return Err(FsError::Conflict(logical.as_str().to_string())); } - atomic_write(&target, &request.content, &logical)?; + access + .atomic_write_file(&path, &target, &request.content, AtomicWriteMode::Replace) + .map_err(|error| map_io(&logical, error))?; } else { if request.expected_hash.is_some() { return Err(FsError::Conflict(logical.as_str().to_string())); } - let target = require_access(&path, &logical, access, true, true)?; - atomic_write(&target, &request.content, &logical)?; + access + .atomic_write_file(&path, &target, &request.content, AtomicWriteMode::CreateNew) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + FsError::Conflict(logical.as_str().to_string()) + } else { + map_io(&logical, error) + } + })?; } Ok(WriteResult { bytes_written: request.content.len(), @@ -241,7 +256,13 @@ pub fn run_edit( .map_err(|error| map_io(&logical, error))?; let path = resolve(root, &logical)?; let target = require_access(&path, &logical, access, true, false)?; - let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?; + let metadata = access + .read_metadata(&path, &target) + .map_err(|error| map_io(&logical, error))?; + if metadata.is_dir() { + return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); + } + let bytes = read_mutation_preimage(&logical, &path, &target, metadata, access)?; let actual_hash = hash_bytes(&bytes); if actual_hash != request.expected_hash { return Err(FsError::Conflict(logical.as_str().to_string())); @@ -260,12 +281,40 @@ pub fn run_edit( "old_string matched {occurrences} times; set replace_all=true or provide a unique string" ))); } + let replacement_count = if request.replace_all { occurrences } else { 1 }; + if let Some(limit) = access.max_edit_replacements() + && replacement_count > limit + { + return Err(FsError::InvalidArgument(format!( + "{} exceeds provider Edit replacement limit {limit}", + logical.as_str() + ))); + } + let removed_bytes = request + .old_string + .len() + .checked_mul(replacement_count) + .ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?; + let added_bytes = request + .new_string + .len() + .checked_mul(replacement_count) + .ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?; + let edited_len = content + .len() + .checked_sub(removed_bytes) + .and_then(|size| size.checked_add(added_bytes)) + .ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?; + enforce_write_bound(&logical, edited_len, access)?; let edited = if request.replace_all { content.replace(&request.old_string, &request.new_string) } else { content.replacen(&request.old_string, &request.new_string, 1) }; - atomic_write(&target, edited.as_bytes(), &logical)?; + debug_assert_eq!(edited.len(), edited_len); + access + .atomic_write_file(&path, &target, edited.as_bytes(), AtomicWriteMode::Replace) + .map_err(|error| map_io(&logical, error))?; Ok(EditResult { replacements: if request.replace_all { occurrences } else { 1 }, bytes_written: edited.len(), @@ -364,6 +413,53 @@ pub fn run_list( }) } +fn enforce_write_bound( + logical: &FsPath, + bytes: usize, + access: &dyn FsAccessPolicy, +) -> Result<(), FsError> { + if let Some(limit) = access.max_write_bytes() + && bytes > limit + { + return Err(FsError::InvalidArgument(format!( + "{} exceeds provider write limit {limit}", + logical.as_str() + ))); + } + Ok(()) +} + +fn read_mutation_preimage( + logical: &FsPath, + path: &Path, + target: &Path, + metadata: std::fs::Metadata, + access: &dyn FsAccessPolicy, +) -> Result, FsError> { + if let Some(limit) = access.max_write_bytes() + && metadata.len() > limit as u64 + { + return Err(FsError::InvalidArgument(format!( + "{} exceeds provider write limit {limit}", + logical.as_str() + ))); + } + let mut file = access + .open_read_file(path, target) + .map_err(|error| map_io(logical, error))?; + let mut bytes = Vec::new(); + if let Some(limit) = access.max_write_bytes() { + file.take(limit.saturating_add(1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| map_io(logical, error))?; + enforce_write_bound(logical, bytes.len(), access)?; + } else { + file.read_to_end(&mut bytes) + .map_err(|error| map_io(logical, error))?; + } + Ok(bytes) +} + fn resolve(root: &Path, logical: &FsPath) -> Result { if !root.is_absolute() { return Err(FsError::RelativePath(root.to_path_buf())); @@ -464,23 +560,6 @@ pub fn resolve_access_path(path: &Path) -> std::io::Result { } } -fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> { - let parent = path - .parent() - .ok_or_else(|| FsError::InvalidArgument(format!("{} has no parent", logical.as_str())))?; - fs::create_dir_all(parent).map_err(|error| map_io(logical, error))?; - let mut temporary = - tempfile::NamedTempFile::new_in(parent).map_err(|error| map_io(logical, error))?; - temporary - .write_all(content) - .map_err(|error| map_io(logical, error))?; - temporary.flush().map_err(|error| map_io(logical, error))?; - temporary - .persist(path) - .map_err(|error| map_io(logical, error.error))?; - Ok(()) -} - fn hash_bytes(content: &[u8]) -> ContentHash { Sha256::digest(content).into() } diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 154f3a773..22e777594 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -73,6 +73,11 @@ pub struct WorkerIdentity { pub runtime_id: String, pub worker_id: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct MergeRequestReviewSubject { + pub merge_request_id: String, + pub subject_ref: String, +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct MergeRequestAuth { pub workspace_id: String, @@ -97,6 +102,10 @@ pub struct ReviewRequestedEvent { #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub sequence: u64, pub subject_ref: String, + #[serde(default)] + pub ticket_item_revision: String, + #[serde(default)] + pub ticket_merge_request_subjects: Vec, pub requested_by: WorkerIdentity, pub reviewer: WorkerIdentity, pub created_at: DateTime, @@ -108,6 +117,10 @@ pub struct ReviewEvent { pub sequence: u64, pub request_event_id: String, pub subject_ref: String, + #[serde(default)] + pub ticket_item_revision: String, + #[serde(default)] + pub ticket_merge_request_subjects: Vec, pub decision: ReviewDecision, pub body: String, pub findings: Vec, @@ -259,7 +272,10 @@ pub struct OpenMergeRequest { } #[derive(Debug, Clone)] pub struct RequestMergeRequestReview { + pub merge_request_id: String, pub ticket_id: String, + pub ticket_item_revision: String, + pub ticket_merge_request_subjects: Vec, pub subject_ref: String, pub child_session_id: String, pub capability_token: String, @@ -282,11 +298,14 @@ pub struct RegisterReviewerChildSession { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReviewSubmissionAuthorization { pub workspace_id: String, + pub merge_request_id: String, + pub ticket_id: String, pub subject_ref: String, } #[derive(Debug, Clone)] pub struct SubmitMergeRequestReview { + pub merge_request_id: String, pub ticket_id: String, pub current_subject_ref: String, pub capability_token: String, @@ -297,6 +316,7 @@ pub struct SubmitMergeRequestReview { } #[derive(Debug, Clone)] pub struct RevokeMergeRequestReview { + pub merge_request_id: String, pub ticket_id: String, pub review_event_id: String, pub reason: String, @@ -306,6 +326,7 @@ pub struct RevokeMergeRequestReview { #[derive(Debug, Clone)] pub struct RepairSelectorFrom { pub workspace_id: String, + pub merge_request_id: String, pub ticket_id: String, pub selector_from: String, pub resolved_subject_ref: String, @@ -315,6 +336,7 @@ pub struct RepairSelectorFrom { } #[derive(Debug, Clone)] pub struct ReadinessCheck { + pub merge_request_id: String, pub ticket_id: String, pub current_subject_ref: Option, pub auth: MergeRequestAuth, @@ -330,6 +352,7 @@ pub struct ReadinessReport { } #[derive(Debug, Clone)] pub struct CompleteMergeRequest { + pub merge_request_id: String, pub ticket_id: String, pub operation_id: String, pub approval_event_id: String, @@ -341,6 +364,26 @@ pub struct CompleteMergeRequest { pub auth: MergeRequestAuth, pub now: DateTime, } +#[derive(Debug, Clone)] +pub struct CompleteTicket { + pub ticket_id: String, + pub operation_id: String, + pub item_revision: String, + pub merge_request_ids: Vec, + pub requirement_approval_event_id: String, + pub auth: MergeRequestAuth, + pub now: DateTime, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketCompletionEvent { + pub operation_id: String, + pub ticket_id: String, + pub item_revision: String, + pub merge_request_ids: Vec, + pub requirement_approval_event_id: String, + pub completed_by: WorkerIdentity, + pub created_at: DateTime, +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct CurrentAssignment { pub assignment_id: String, @@ -421,13 +464,14 @@ impl MergeRequestStore { "selectors must differ".into(), )); } + let merge_request_id = i.merge_request_id.clone(); self.assigned(&i.auth, &i.ticket_id, &i.repository_id)?; let mut c = self.lock()?; let t = c.transaction()?; - let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?; + let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.repository_id=?3 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id,i.repository_id],|r|r.get(0))?; if conflict { return Err(MergeRequestError::Conflict( - "Ticket already has an open Merge Request; use ShowMergeRequest and advance the existing selector_from with a normal non-force push instead of opening a replacement Merge Request or adding a revision".into(), + "Ticket already has an open Merge Request for this repository; advance that Merge Request's existing selector_from with a normal non-force push instead of opening a replacement".into(), )); } let now = i.now.to_rfc3339(); @@ -469,7 +513,7 @@ impl MergeRequestStore { } t.commit()?; drop(c); - self.get(&i.auth.workspace_id, &i.ticket_id) + self.get_by_id(&i.auth.workspace_id, &merge_request_id) } pub fn register_reviewer_child_session( &self, @@ -495,18 +539,74 @@ impl MergeRequestStore { } pub fn request_review( &self, - i: RequestMergeRequestReview, + mut i: RequestMergeRequestReview, ) -> Result { nonempty("subject_ref", &i.subject_ref)?; - let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + if i.ticket_merge_request_subjects.is_empty() { + return Err(MergeRequestError::Validation( + "review must bind the linked Merge Request subject set".into(), + )); + } + for subject in &i.ticket_merge_request_subjects { + nonempty("merge_request_id", &subject.merge_request_id)?; + nonempty("subject_ref", &subject.subject_ref)?; + } + i.ticket_merge_request_subjects + .sort_by(|left, right| left.merge_request_id.cmp(&right.merge_request_id)); + if i.ticket_merge_request_subjects + .windows(2) + .any(|subjects| subjects[0].merge_request_id == subjects[1].merge_request_id) + { + return Err(MergeRequestError::Validation( + "review Merge Request subject set must not contain duplicates".into(), + )); + } + if !i.ticket_merge_request_subjects.iter().any(|subject| { + subject.merge_request_id == i.merge_request_id && subject.subject_ref == i.subject_ref + }) { + return Err(MergeRequestError::Validation( + "review subject set does not contain the addressed Merge Request subject".into(), + )); + } + let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?; self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?; - if mr.state != MergeRequestState::Open || mr.selector_from.is_none() { + if mr.state == MergeRequestState::Closed + || (mr.state == MergeRequestState::Open && mr.selector_from.is_none()) + { return Err(MergeRequestError::Conflict( "Merge Request is not reviewable".into(), )); } let mut c = self.lock()?; let t = c.transaction()?; + let current_revision = current_ticket_revision(&t, &i.auth.workspace_id, &i.ticket_id)? + .ok_or(MergeRequestError::NotFound)?; + if current_revision != i.ticket_item_revision { + return Err(MergeRequestError::Conflict( + "Ticket item revision changed before review request".into(), + )); + } + let current_mr = load_mr(&t, &i.auth.workspace_id, &i.merge_request_id)? + .filter(|request| request.ticket_ids.iter().any(|id| id == &i.ticket_id)) + .ok_or(MergeRequestError::NotFound)?; + if current_mr.state == MergeRequestState::Closed + || (current_mr.state == MergeRequestState::Open && current_mr.selector_from.is_none()) + { + return Err(MergeRequestError::Conflict( + "Merge Request changed before review request".into(), + )); + } + let linked_ids = linked_merge_request_ids(&t, &i.auth.workspace_id, &i.ticket_id)?; + let subject_ids = i + .ticket_merge_request_subjects + .iter() + .map(|subject| subject.merge_request_id.clone()) + .collect::>(); + if linked_ids != subject_ids { + return Err(MergeRequestError::Conflict( + "linked Merge Request set changed before review request".into(), + )); + } let child:Option<(String,String,String)>=t.query_row("SELECT parent_runtime_id,child_session_id,reviewer_profile FROM merge_request_reviewer_child_sessions WHERE workspace_id=?1 AND child_session_id=?2 AND parent_runtime_id=?3 AND parent_worker_id=?4 AND status='active'",params![i.auth.workspace_id,i.child_session_id,i.auth.runtime_id,i.auth.worker_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?))).optional()?; let Some((rr, rw, p)) = child else { return Err(MergeRequestError::Unauthorized( @@ -522,6 +622,8 @@ impl MergeRequestStore { event_id: Uuid::now_v7().to_string(), sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, subject_ref: i.subject_ref, + ticket_item_revision: i.ticket_item_revision, + ticket_merge_request_subjects: i.ticket_merge_request_subjects, requested_by: WorkerIdentity { runtime_id: i.auth.runtime_id, worker_id: i.auth.worker_id, @@ -548,25 +650,27 @@ impl MergeRequestStore { } pub fn authorize_review_submission( &self, - ticket_id: &str, + merge_request_id: &str, capability_token: &str, ) -> Result { let connection = self.lock()?; connection .query_row( - "SELECT g.workspace_id,g.subject_ref + "SELECT g.workspace_id,g.merge_request_id,rel.ticket_id,g.subject_ref FROM merge_request_review_grants g JOIN merge_request_ticket_relations rel ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id JOIN merge_requests mr ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id - WHERE g.capability_token=?1 AND rel.ticket_id=?2 - AND g.status='issued' AND mr.state='open'", - params![capability_token, ticket_id], + WHERE g.capability_token=?1 AND g.merge_request_id=?2 + AND g.status='issued' AND mr.state IN('open','merged')", + params![capability_token, merge_request_id], |row| { Ok(ReviewSubmissionAuthorization { workspace_id: row.get(0)?, - subject_ref: row.get(1)?, + merge_request_id: row.get(1)?, + ticket_id: row.get(2)?, + subject_ref: row.get(3)?, }) }, ) @@ -594,8 +698,9 @@ impl MergeRequestStore { JOIN merge_requests mr ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id WHERE g.capability_token=?1 AND rel.ticket_id=?2 - AND g.status='issued' AND mr.state='open'", - params![i.capability_token, i.ticket_id], + AND g.merge_request_id=?3 + AND g.status='issued' AND mr.state IN('open','merged')", + params![i.capability_token, i.ticket_id, i.merge_request_id], |r| { Ok(( r.get(0)?, @@ -613,6 +718,19 @@ impl MergeRequestStore { "review grant invalid".into(), )); }; + let (ticket_item_revision, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)? + .and_then(|request| { + request.thread.into_iter().find_map(|event| match event { + MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => { + Some(( + event.ticket_item_revision, + event.ticket_merge_request_subjects, + )) + } + _ => None, + }) + }) + .ok_or_else(|| MergeRequestError::Corrupt("review request event missing".into()))?; if subject != i.current_subject_ref { let reason = format!( "selector_from moved from requested subject {subject} to current subject {}; fresh review of the exact current source ref is required", @@ -638,6 +756,8 @@ impl MergeRequestStore { sequence: next_seq(&t, &ws, &mr)?, request_event_id: req, subject_ref: subject, + ticket_item_revision, + ticket_merge_request_subjects, decision: i.decision, body: i.body, findings: i.findings, @@ -657,7 +777,7 @@ impl MergeRequestStore { i: RevokeMergeRequestReview, ) -> Result { bounded_body("revocation reason", &i.reason)?; - let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?; self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?; let r = mr .thread @@ -695,7 +815,7 @@ impl MergeRequestStore { Ok(e) } pub fn readiness(&self, i: ReadinessCheck) -> Result { - let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?; let review = i .current_subject_ref .as_deref() @@ -708,6 +828,18 @@ impl MergeRequestStore { if mr.selector_from.is_none() { b.push("selector_from requires repair".into()) } + if let Some(review) = &review { + let connection = self.lock()?; + let current_revision = + current_ticket_revision(&connection, &i.auth.workspace_id, &i.ticket_id)? + .ok_or(MergeRequestError::NotFound)?; + if review.ticket_item_revision != current_revision { + b.push( + "approval predates the current Ticket revision; fresh review is required" + .into(), + ); + } + } match (&i.current_subject_ref, &review) { (None, _) => b.push("selector_from could not be resolved".into()), (Some(subject_ref), None) => { @@ -742,7 +874,7 @@ impl MergeRequestStore { }) } pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> { - let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?; self.repo(&i.auth, &mr.repository_id)?; if let Some(existing) = mr.thread.iter().find_map(|event| match event { MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => { @@ -788,8 +920,16 @@ impl MergeRequestStore { "target ref did not change".into(), )); } - let state: Option = self - .lock()? + let connection = self.lock()?; + let current_revision = + current_ticket_revision(&connection, &mr.workspace_id, &i.ticket_id)? + .ok_or(MergeRequestError::NotFound)?; + if review.ticket_item_revision != current_revision { + return Err(MergeRequestError::NotReady( + "approval predates the current Ticket revision; fresh review is required".into(), + )); + } + let state: Option = connection .query_row( "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![mr.workspace_id, i.ticket_id], @@ -808,7 +948,8 @@ impl MergeRequestStore { self.validate_completion(&i)?; let mut connection = self.lock()?; let transaction = connection.transaction()?; - let mr = load_mr_for_ticket(&transaction, &i.auth.workspace_id, &i.ticket_id)? + let mr = load_mr(&transaction, &i.auth.workspace_id, &i.merge_request_id)? + .filter(|mr| mr.ticket_ids.iter().any(|id| id == &i.ticket_id)) .ok_or(MergeRequestError::NotFound)?; if let Some(existing) = mr.thread.iter().find_map(|event| match event { @@ -860,6 +1001,14 @@ impl MergeRequestStore { "current effective review does not approve the source ref".into(), )); } + let current_revision = + current_ticket_revision(&transaction, &mr.workspace_id, &i.ticket_id)? + .ok_or(MergeRequestError::NotFound)?; + if review.ticket_item_revision != current_revision { + return Err(MergeRequestError::NotReady( + "approval predates the current Ticket revision; fresh review is required".into(), + )); + } let state: Option = transaction .query_row( "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", @@ -872,22 +1021,6 @@ impl MergeRequestStore { "Ticket must be inprogress".into(), )); } - transaction.execute( - "UPDATE typed_tickets - SET workflow_state='done',workflow_state_explicit=1,updated_at=?3 - WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'", - params![mr.workspace_id, i.ticket_id, i.now.to_rfc3339()], - )?; - let released_assignment = transaction.execute( - "DELETE FROM ticket_current_worker_assignments - WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3", - params![mr.workspace_id, i.ticket_id, i.auth.assignment_id], - )?; - if released_assignment != 1 { - return Err(MergeRequestError::Unauthorized( - "completion assignment changed while closing Ticket".into(), - )); - } let issued_grants = { let mut statement = transaction.prepare( "SELECT request_event_id,subject_ref,capability_token @@ -965,13 +1098,234 @@ impl MergeRequestStore { Ok(event) } + pub fn complete_ticket( + &self, + mut input: CompleteTicket, + ) -> Result { + nonempty("operation_id", &input.operation_id)?; + nonempty("item_revision", &input.item_revision)?; + nonempty( + "requirement_approval_event_id", + &input.requirement_approval_event_id, + )?; + if input.merge_request_ids.is_empty() { + return Err(MergeRequestError::NotReady( + "Ticket completion requires at least one linked Merge Request result".into(), + )); + } + input.merge_request_ids.sort(); + if input + .merge_request_ids + .windows(2) + .any(|ids| ids[0] == ids[1]) + { + return Err(MergeRequestError::Validation( + "merge_request_ids must not contain duplicates".into(), + )); + } + let mut connection = self.lock()?; + let transaction = connection.transaction()?; + let existing: Option = transaction + .query_row( + "SELECT payload.value + FROM typed_ticket_event_attributes operation + JOIN typed_ticket_event_attributes payload + ON payload.workspace_id=operation.workspace_id + AND payload.ticket_id=operation.ticket_id + AND payload.event_index=operation.event_index + AND payload.key='ticket_completion' + WHERE operation.workspace_id=?1 AND operation.ticket_id=?2 + AND operation.key='operation_id' AND operation.value=?3", + params![input.auth.workspace_id, input.ticket_id, input.operation_id], + |row| row.get(0), + ) + .optional()?; + if let Some(payload) = existing { + let event: TicketCompletionEvent = json(&payload)?; + if event.item_revision == input.item_revision + && event.merge_request_ids == input.merge_request_ids + && event.requirement_approval_event_id == input.requirement_approval_event_id + && event.completed_by == input.auth.actor() + { + return Ok(event); + } + return Err(MergeRequestError::Conflict( + "Ticket completion operation fingerprint mismatch".into(), + )); + } + let current_revision = + current_ticket_revision(&transaction, &input.auth.workspace_id, &input.ticket_id)? + .ok_or(MergeRequestError::NotFound)?; + if current_revision != input.item_revision { + return Err(MergeRequestError::Conflict(format!( + "Ticket item revision changed from `{}` to `{current_revision}`; repeat requirement review before completion", + input.item_revision + ))); + } + let state: String = transaction.query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + params![input.auth.workspace_id, input.ticket_id], + |row| row.get(0), + )?; + if state != "inprogress" { + return Err(MergeRequestError::Conflict( + "Ticket must be inprogress".into(), + )); + } + let assignment_is_current: bool = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM ticket_current_worker_assignments + WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 + )", + params![ + input.auth.workspace_id, + input.ticket_id, + input.auth.assignment_id + ], + |row| row.get(0), + )?; + if !assignment_is_current { + return Err(MergeRequestError::Unauthorized( + "Ticket completion assignment changed before commit".into(), + )); + } + let actual_ids = + linked_merge_request_ids(&transaction, &input.auth.workspace_id, &input.ticket_id)?; + if actual_ids != input.merge_request_ids { + return Err(MergeRequestError::Conflict( + "linked Merge Request set changed; refresh Ticket completion evidence".into(), + )); + } + let mut requests = Vec::with_capacity(actual_ids.len()); + let mut merged_subjects = Vec::with_capacity(actual_ids.len()); + for merge_request_id in &actual_ids { + let request = load_mr(&transaction, &input.auth.workspace_id, merge_request_id)? + .ok_or(MergeRequestError::NotFound)?; + if request.state != MergeRequestState::Merged { + return Err(MergeRequestError::NotReady(format!( + "Merge Request `{merge_request_id}` has no merged result" + ))); + } + let merge = request + .thread + .iter() + .rev() + .find_map(|event| match event { + MergeRequestThreadEvent::Merge(event) => Some(event), + _ => None, + }) + .ok_or_else(|| { + MergeRequestError::Corrupt(format!( + "merged Merge Request `{merge_request_id}` has no MergeResult" + )) + })?; + let review = request + .thread + .iter() + .find_map(|event| match event { + MergeRequestThreadEvent::Review(review) + if review.event_id == merge.approval_event_id + && review.subject_ref == merge.approved_source_ref => + { + Some(review) + } + _ => None, + }) + .filter(|review| { + !request.thread.iter().any(|event| { + matches!( + event, + MergeRequestThreadEvent::ReviewRevoked(revoked) + if revoked.review_event_id == review.event_id + ) + }) + }) + .ok_or_else(|| { + MergeRequestError::NotReady(format!( + "Merge Request `{merge_request_id}` integration approval is missing or revoked" + )) + })?; + if review.decision != ReviewDecision::Approve { + return Err(MergeRequestError::NotReady(format!( + "Merge Request `{merge_request_id}` lacks an approved integration result" + ))); + } + merged_subjects.push(MergeRequestReviewSubject { + merge_request_id: merge_request_id.clone(), + subject_ref: merge.approved_source_ref.clone(), + }); + requests.push(request); + } + let has_requirement_attestation = requests.iter().any(|request| { + request.thread.iter().rev().any(|event| { + let MergeRequestThreadEvent::Review(review) = event else { + return false; + }; + review.event_id == input.requirement_approval_event_id + && review.decision == ReviewDecision::Approve + && review.ticket_item_revision == input.item_revision + && review.ticket_merge_request_subjects == merged_subjects + && request + .effective_review(&review.subject_ref) + .is_some_and(|effective| effective.event_id == review.event_id) + }) + }); + if !has_requirement_attestation { + return Err(MergeRequestError::NotReady( + "no effective Reviewer approval attests the current Ticket revision and exact merged source set" + .into(), + )); + } + let updated = transaction.execute( + "UPDATE typed_tickets + SET workflow_state='done',workflow_state_explicit=1,updated_at=?3 + WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'", + params![ + input.auth.workspace_id, + input.ticket_id, + input.now.to_rfc3339() + ], + )?; + if updated != 1 { + return Err(MergeRequestError::Conflict( + "Ticket state changed during completion".into(), + )); + } + let released = transaction.execute( + "DELETE FROM ticket_current_worker_assignments + WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3", + params![ + input.auth.workspace_id, + input.ticket_id, + input.auth.assignment_id + ], + )?; + if released != 1 { + return Err(MergeRequestError::Unauthorized( + "Ticket completion assignment changed while releasing it".into(), + )); + } + let event = TicketCompletionEvent { + operation_id: input.operation_id, + ticket_id: input.ticket_id.clone(), + item_revision: input.item_revision, + merge_request_ids: input.merge_request_ids, + requirement_approval_event_id: input.requirement_approval_event_id, + completed_by: input.auth.actor(), + created_at: input.now, + }; + ticket_completion_event(&transaction, &input.auth.workspace_id, &event)?; + transaction.commit()?; + Ok(event) + } + pub fn repair_selector_from( &self, i: RepairSelectorFrom, ) -> Result { nonempty("selector_from", &i.selector_from)?; nonempty("resolved_subject_ref", &i.resolved_subject_ref)?; - let mr = self.get(&i.workspace_id, &i.ticket_id)?; + let mr = self.get_for_operation(&i.workspace_id, &i.merge_request_id, &i.ticket_id)?; if mr.selector_from.is_some() { return Err(MergeRequestError::Conflict( "selector_from is immutable after it is set".into(), @@ -1011,7 +1365,7 @@ impl MergeRequestStore { )?; t.commit()?; drop(c); - self.get(&i.workspace_id, &i.ticket_id) + self.get_by_id(&i.workspace_id, &i.merge_request_id) } pub fn get(&self, ws: &str, ticket: &str) -> Result { let c = self.lock()?; @@ -1021,6 +1375,21 @@ impl MergeRequestStore { None => Err(MergeRequestError::NotFound), } } + fn get_for_operation( + &self, + workspace_id: &str, + merge_request_id: &str, + ticket_id: &str, + ) -> Result { + let mr = self.get_by_id(workspace_id, merge_request_id)?; + if !mr.ticket_ids.iter().any(|id| id == ticket_id) { + return Err(MergeRequestError::Unauthorized( + "Merge Request is not linked to the addressed Ticket".into(), + )); + } + Ok(mr) + } + pub fn get_by_id( &self, workspace_id: &str, @@ -1029,6 +1398,23 @@ impl MergeRequestStore { let c = self.lock()?; load_mr(&c, workspace_id, merge_request_id)?.ok_or(MergeRequestError::NotFound) } + pub fn list_for_ticket( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result, MergeRequestError> { + Ok(self + .list( + workspace_id, + &MergeRequestListQuery { + ticket_id: Some(ticket_id.to_string()), + limit: 100, + ..Default::default() + }, + )? + .items) + } + pub fn list( &self, workspace_id: &str, @@ -1248,29 +1634,55 @@ fn insert_event( )?; Ok(()) } -fn load_mr_for_ticket( +fn linked_merge_request_ids( connection: &Connection, workspace_id: &str, ticket_id: &str, -) -> Result, MergeRequestError> { - let merge_request_id: Option = connection +) -> Result, MergeRequestError> { + let mut statement = connection.prepare( + "SELECT merge_request_id FROM merge_request_ticket_relations + WHERE workspace_id=?1 AND ticket_id=?2 AND relation_kind='implements' + ORDER BY merge_request_id", + )?; + statement + .query_map(params![workspace_id, ticket_id], |row| row.get(0))? + .collect::, _>>() + .map_err(Into::into) +} + +fn current_ticket_revision( + connection: &Connection, + workspace_id: &str, + ticket_id: &str, +) -> Result, MergeRequestError> { + let event_revision = connection .query_row( - "SELECT rel.merge_request_id - FROM merge_request_ticket_relations rel - JOIN merge_requests mr - ON mr.workspace_id=rel.workspace_id - AND mr.merge_request_id=rel.merge_request_id - WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 - ORDER BY CASE mr.state WHEN 'open' THEN 0 ELSE 1 END,mr.created_at DESC + "SELECT attribute.value + FROM typed_ticket_events event + JOIN typed_ticket_event_attributes attribute + ON attribute.workspace_id=event.workspace_id + AND attribute.ticket_id=event.ticket_id + AND attribute.event_index=event.event_index + AND attribute.key='event_id' + WHERE event.workspace_id=?1 AND event.ticket_id=?2 + AND event.kind IN ('create','item_edit') + ORDER BY event.event_index DESC LIMIT 1", params![workspace_id, ticket_id], |row| row.get(0), ) .optional()?; - match merge_request_id { - Some(id) => load_mr(connection, workspace_id, &id), - None => Ok(None), + if event_revision.is_some() { + return Ok(event_revision); } + connection + .query_row( + "SELECT updated_at FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + params![workspace_id, ticket_id], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) } fn load_mr(c: &Connection, w: &str, m: &str) -> Result, MergeRequestError> { @@ -1341,9 +1753,10 @@ fn ticket_event( ) -> Result<(), MergeRequestError> { let ticket = &mr.ticket_ids[0]; let n:i64=t.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,ticket],|r|r.get(0))?; - t.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)VALUES(?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Merge Request completed',?6)",params![mr.workspace_id,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Approved source ref `{}` completed implementation.",e.approved_source_ref)])?; + t.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6)",params![mr.workspace_id,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",mr.merge_request_id,e.approved_source_ref)])?; for (k, v) in [ ("implementation_assignment_id", a), + ("merge_request_id", &mr.merge_request_id), ("approval_event_id", &e.approval_event_id), ("approved_source_ref", &e.approved_source_ref), ("operation_id", &e.operation_id), @@ -1356,6 +1769,62 @@ fn ticket_event( Ok(()) } +fn ticket_completion_event( + transaction: &Transaction<'_>, + workspace_id: &str, + event: &TicketCompletionEvent, +) -> Result<(), MergeRequestError> { + let event_index: i64 = transaction.query_row( + "SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2", + params![workspace_id, event.ticket_id], + |row| row.get(0), + )?; + transaction.execute( + "INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body) + VALUES(?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Ticket requirements completed',?6)", + params![ + workspace_id, + event.ticket_id, + event_index, + format!( + "worker:{}:{}", + event.completed_by.runtime_id, event.completed_by.worker_id + ), + event.created_at.to_rfc3339(), + format!( + "Current Ticket revision `{}` was approved by Review `{}` and completed with Merge Requests: {}.", + event.item_revision, + event.requirement_approval_event_id, + event.merge_request_ids.join(", ") + ) + ], + )?; + for (key, value) in [ + ("operation_id", event.operation_id.clone()), + ("item_revision", event.item_revision.clone()), + ( + "requirement_approval_event_id", + event.requirement_approval_event_id.clone(), + ), + ( + "merge_request_ids", + serde_json::to_string(&event.merge_request_ids) + .map_err(|error| MergeRequestError::Operation(error.to_string()))?, + ), + ( + "ticket_completion", + serde_json::to_string(event) + .map_err(|error| MergeRequestError::Operation(error.to_string()))?, + ), + ] { + transaction.execute( + "INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)", + params![workspace_id, event.ticket_id, event_index, key, value], + )?; + } + Ok(()) +} + pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> { match schema_state(c)? { SchemaState::Fresh => fresh(c), diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index 96ea75838..0485f11da 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -12,7 +12,7 @@ impl AssignmentSource for Assignments { struct Repositories; impl RepositorySource for Repositories { fn repository_belongs_to_workspace(&self, w: &str, r: &str) -> Result { - Ok(w == "W" && r == "R") + Ok(w == "W" && matches!(r, "R" | "R2")) } } fn at(s: u32) -> chrono::DateTime { @@ -29,11 +29,17 @@ fn auth() -> MergeRequestAuth { assignment_id: "A".into(), } } +fn auth_repo(repository_id: &str) -> MergeRequestAuth { + MergeRequestAuth { + repository_id: repository_id.into(), + ..auth() + } +} fn fixture() -> (tempfile::TempDir, MergeRequestStore) { let d = tempfile::tempdir().unwrap(); let p = d.path().join("db"); let c = Connection::open(&p).unwrap(); - c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap(); + c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap(); drop(c); let a = Assignments(Arc::new(Mutex::new(CurrentAssignment { assignment_id: "A".into(), @@ -44,20 +50,33 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) { let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap(); (d, s) } -fn open(s: &MergeRequestStore) { +fn open_for(s: &MergeRequestStore, merge_request_id: &str, repository_id: &str) { s.open_merge_request(OpenMergeRequest { - merge_request_id: "MR".into(), + merge_request_id: merge_request_id.into(), ticket_id: "T".into(), - repository_id: "R".into(), - selector_from: "work/t".into(), + repository_id: repository_id.into(), + selector_from: if merge_request_id == "MR" { + "work/t".into() + } else { + format!("work/{merge_request_id}") + }, selector_to: "develop".into(), summary: "summary".into(), - auth: auth(), + auth: auth_repo(repository_id), now: at(1), }) .unwrap(); } -fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequestedEvent { +fn open(s: &MergeRequestStore) { + open_for(s, "MR", "R"); +} +fn request_for( + s: &MergeRequestStore, + merge_request_id: &str, + repository_id: &str, + subject: &str, + token: &str, +) -> ReviewRequestedEvent { s.register_reviewer_child_session(RegisterReviewerChildSession { workspace_id: "W".into(), parent_runtime_id: "runtime".into(), @@ -67,20 +86,53 @@ fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequested now: at(2), }) .unwrap(); + let ticket_merge_request_subjects = s + .list_for_ticket("W", "T") + .unwrap() + .into_iter() + .map(|request| { + let other_subject = match request.merge_request_id.as_str() { + "MR" => "subject-one", + "MR-2" => "subject-two", + _ => subject, + }; + MergeRequestReviewSubject { + subject_ref: if request.merge_request_id == merge_request_id { + subject.into() + } else { + other_subject.into() + }, + merge_request_id: request.merge_request_id, + } + }) + .collect(); s.request_review(RequestMergeRequestReview { + merge_request_id: merge_request_id.into(), + ticket_item_revision: "t".into(), + ticket_merge_request_subjects, ticket_id: "T".into(), subject_ref: subject.into(), child_session_id: format!("child-{token}"), capability_token: token.into(), - auth: auth(), + auth: auth_repo(repository_id), now: at(3), }) .unwrap() .request_event } -fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent { - request(s, subject, token); +fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequestedEvent { + request_for(s, "MR", "R", subject, token) +} +fn approve_for( + s: &MergeRequestStore, + merge_request_id: &str, + repository_id: &str, + subject: &str, + token: &str, +) -> ReviewEvent { + request_for(s, merge_request_id, repository_id, subject, token); s.submit_review(SubmitMergeRequestReview { + merge_request_id: merge_request_id.into(), ticket_id: "T".into(), current_subject_ref: subject.into(), capability_token: token.into(), @@ -91,6 +143,9 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent { }) .unwrap() } +fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent { + approve_for(s, "MR", "R", subject, token) +} #[test] fn review_submission_authorization_rejects_invalid_grants_before_side_effects() { let (_d, store) = fixture(); @@ -98,13 +153,15 @@ fn review_submission_authorization_rejects_invalid_grants_before_side_effects() request(&store, "published-source", "valid-token"); let invalid = store - .authorize_review_submission("T", "invalid-token") + .authorize_review_submission("MR", "invalid-token") .unwrap_err(); assert!(matches!(invalid, MergeRequestError::Unauthorized(_))); let authorized = store - .authorize_review_submission("T", "valid-token") + .authorize_review_submission("MR", "valid-token") .unwrap(); assert_eq!(authorized.workspace_id, "W"); + assert_eq!(authorized.merge_request_id, "MR"); + assert_eq!(authorized.ticket_id, "T"); assert_eq!(authorized.subject_ref, "published-source"); } @@ -115,6 +172,7 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() { let review = approve(&s, "opaque-source-ref", "token"); let ready = s .readiness(ReadinessCheck { + merge_request_id: "MR".into(), ticket_id: "T".into(), current_subject_ref: Some("opaque-source-ref".into()), auth: auth(), @@ -123,6 +181,7 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() { assert!(ready.ready); let merged = s .complete(CompleteMergeRequest { + merge_request_id: "MR".into(), ticket_id: "T".into(), operation_id: "op".into(), approval_event_id: review.event_id, @@ -150,9 +209,10 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() { |row| row.get(0), ) .unwrap(); - assert!(!current_assignment); + assert!(current_assignment); let replayed = s .complete(CompleteMergeRequest { + merge_request_id: "MR".into(), ticket_id: "T".into(), operation_id: "op".into(), approval_event_id: merged.approval_event_id.clone(), @@ -166,6 +226,43 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() { }) .unwrap(); assert_eq!(replayed, merged); + let completed = s + .complete_ticket(CompleteTicket { + ticket_id: "T".into(), + operation_id: "ticket-op".into(), + item_revision: "t".into(), + merge_request_ids: vec!["MR".into()], + requirement_approval_event_id: merged.approval_event_id.clone(), + auth: auth(), + now: at(7), + }) + .unwrap(); + assert_eq!(completed.merge_request_ids, vec!["MR"]); + let connection = Connection::open(d.path().join("db")).unwrap(); + let (state, assignment_exists): (String, bool) = connection + .query_row( + "SELECT workflow_state, EXISTS( + SELECT 1 FROM ticket_current_worker_assignments + WHERE workspace_id='W' AND ticket_id='T' + ) FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(state, "done"); + assert!(!assignment_exists); + let replayed_completion = s + .complete_ticket(CompleteTicket { + ticket_id: "T".into(), + operation_id: "ticket-op".into(), + item_revision: "t".into(), + merge_request_ids: vec!["MR".into()], + requirement_approval_event_id: merged.approval_event_id.clone(), + auth: auth(), + now: at(8), + }) + .unwrap(); + assert_eq!(replayed_completion, completed); let json = serde_json::to_string(&mr).unwrap(); for banned in [ "revision_id", @@ -187,6 +284,7 @@ fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_retur request(&s, "source-b", "two"); assert!( s.submit_review(SubmitMergeRequestReview { + merge_request_id: "MR".into(), ticket_id: "T".into(), current_subject_ref: "source-c".into(), capability_token: "two".into(), @@ -221,6 +319,7 @@ fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_o let stale = s .readiness(ReadinessCheck { + merge_request_id: "MR".into(), ticket_id: "T".into(), current_subject_ref: Some("source-2".into()), auth: auth(), @@ -244,6 +343,7 @@ fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_o let second = approve(&s, "source-2", "two"); let ready = s .readiness(ReadinessCheck { + merge_request_id: "MR".into(), ticket_id: "T".into(), current_subject_ref: Some("source-2".into()), auth: auth(), @@ -260,12 +360,13 @@ fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_o // integration evidence for the current target pair. let merged = s .complete(CompleteMergeRequest { + merge_request_id: "MR".into(), operation_id: "target-moved".into(), ticket_id: "T".into(), current_subject_ref: "source-2".into(), target_ref_before: "target-2".into(), target_ref_after: "integrated-target-2".into(), - approval_event_id: second.event_id, + approval_event_id: second.event_id.clone(), strategy: MergeStrategy::FastForward, resolution: ConflictResolution::None, auth: auth(), @@ -283,6 +384,7 @@ fn review_revocation_invalidates_readiness() { open(&s); let review = approve(&s, "source", "one"); s.revoke_review(RevokeMergeRequestReview { + merge_request_id: "MR".into(), ticket_id: "T".into(), review_event_id: review.event_id, reason: "bad evidence".into(), @@ -292,6 +394,7 @@ fn review_revocation_invalidates_readiness() { .unwrap(); let r = s .readiness(ReadinessCheck { + merge_request_id: "MR".into(), ticket_id: "T".into(), current_subject_ref: Some("source".into()), auth: auth(), @@ -366,6 +469,7 @@ fn completion_rejects_superseded_approval_for_same_subject() { request(&store, "subject", "changes"); store .submit_review(SubmitMergeRequestReview { + merge_request_id: "MR".into(), ticket_id: "T".into(), current_subject_ref: "subject".into(), capability_token: "changes".into(), @@ -376,6 +480,7 @@ fn completion_rejects_superseded_approval_for_same_subject() { }) .unwrap(); let result = store.complete(CompleteMergeRequest { + merge_request_id: "MR".into(), ticket_id: "T".into(), operation_id: "op".into(), approval_event_id: old_approval.event_id, @@ -398,6 +503,7 @@ fn completion_cancels_outstanding_grants_and_late_submit_fails() { request(&store, "other-subject", "pending"); store .complete(CompleteMergeRequest { + merge_request_id: "MR".into(), ticket_id: "T".into(), operation_id: "op".into(), approval_event_id: approval.event_id, @@ -411,6 +517,7 @@ fn completion_cancels_outstanding_grants_and_late_submit_fails() { }) .unwrap(); let late = store.submit_review(SubmitMergeRequestReview { + merge_request_id: "MR".into(), ticket_id: "T".into(), current_subject_ref: "other-subject".into(), capability_token: "pending".into(), @@ -436,6 +543,7 @@ fn selector_repair_requires_and_accepts_an_approved_resolved_subject() { .unwrap(); let repaired = store .repair_selector_from(RepairSelectorFrom { + merge_request_id: "MR".into(), workspace_id: "W".into(), ticket_id: "T".into(), selector_from: "restored-work".into(), @@ -460,6 +568,7 @@ fn selector_repair_rejects_unapproved_resolved_subject() { .execute("UPDATE merge_requests SET selector_from=NULL WHERE workspace_id='W' AND merge_request_id='MR'", []) .unwrap(); let result = store.repair_selector_from(RepairSelectorFrom { + merge_request_id: "MR".into(), workspace_id: "W".into(), ticket_id: "T".into(), selector_from: "wrong-work".into(), @@ -552,6 +661,7 @@ fn transactional_completion_rejects_assignment_changed_in_control_plane_db() { .execute("UPDATE ticket_current_worker_assignments SET assignment_id='B' WHERE workspace_id='W' AND ticket_id='T'", []) .unwrap(); let result = store.complete(CompleteMergeRequest { + merge_request_id: "MR".into(), ticket_id: "T".into(), operation_id: "op".into(), approval_event_id: approval.event_id, @@ -574,3 +684,417 @@ fn transactional_completion_rejects_assignment_changed_in_control_plane_db() { .unwrap(); assert_eq!(state, "inprogress"); } + +#[test] +fn ticket_can_link_parallel_open_merge_requests_in_distinct_repositories() { + let (_dir, store) = fixture(); + open_for(&store, "MR", "R"); + open_for(&store, "MR-2", "R2"); + + let linked = store.list_for_ticket("W", "T").unwrap(); + assert_eq!( + linked + .iter() + .map(|request| request.merge_request_id.as_str()) + .collect::>(), + vec!["MR", "MR-2"] + ); + let duplicate = store.open_merge_request(OpenMergeRequest { + merge_request_id: "MR-duplicate".into(), + ticket_id: "T".into(), + repository_id: "R2".into(), + selector_from: "work/duplicate".into(), + selector_to: "develop".into(), + summary: String::new(), + auth: auth_repo("R2"), + now: at(2), + }); + assert!(matches!(duplicate, Err(MergeRequestError::Conflict(_)))); +} + +#[test] +fn mr_operations_and_review_capabilities_are_bound_to_explicit_identity() { + let (_dir, store) = fixture(); + open_for(&store, "MR", "R"); + open_for(&store, "MR-2", "R2"); + request_for(&store, "MR", "R", "subject-one", "token-one"); + + assert!(matches!( + store.authorize_review_submission("MR-2", "token-one"), + Err(MergeRequestError::Unauthorized(_)) + )); + assert!(matches!( + store.submit_review(SubmitMergeRequestReview { + merge_request_id: "MR-2".into(), + ticket_id: "T".into(), + current_subject_ref: "subject-one".into(), + capability_token: "token-one".into(), + decision: ReviewDecision::Approve, + body: "wrong MR".into(), + findings: vec![], + now: at(4), + }), + Err(MergeRequestError::Unauthorized(_)) + )); + assert!( + !store + .get_by_id("W", "MR-2") + .unwrap() + .thread + .iter() + .any(|event| matches!(event, MergeRequestThreadEvent::Review(_))) + ); +} + +#[test] +fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() { + let (dir, store) = fixture(); + open(&store); + let stale = approve(&store, "subject", "token-stale"); + let connection = Connection::open(dir.path().join("db")).unwrap(); + connection + .execute( + "INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO typed_ticket_event_attributes VALUES('W','T',1,'event_id','t2')", + [], + ) + .unwrap(); + drop(connection); + + let readiness = store + .readiness(ReadinessCheck { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + current_subject_ref: Some("subject".into()), + auth: auth(), + }) + .unwrap(); + assert!(!readiness.ready); + assert!( + readiness + .blockers + .iter() + .any(|blocker| blocker.contains("Ticket revision")) + ); + + let stale_completion = CompleteMergeRequest { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + operation_id: "merge-stale".into(), + approval_event_id: stale.event_id, + current_subject_ref: "subject".into(), + target_ref_before: "target-before".into(), + target_ref_after: "target-after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(5), + }; + assert!(matches!( + store.validate_completion(&stale_completion), + Err(MergeRequestError::NotReady(_)) + )); + assert!(matches!( + store.complete(stale_completion), + Err(MergeRequestError::NotReady(_)) + )); + + store + .register_reviewer_child_session(RegisterReviewerChildSession { + workspace_id: "W".into(), + parent_runtime_id: "runtime".into(), + parent_worker_id: "coder".into(), + child_session_id: "child-fresh".into(), + reviewer_profile: "builtin:reviewer".into(), + now: at(6), + }) + .unwrap(); + store + .request_review(RequestMergeRequestReview { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + ticket_item_revision: "t2".into(), + ticket_merge_request_subjects: vec![MergeRequestReviewSubject { + merge_request_id: "MR".into(), + subject_ref: "subject".into(), + }], + subject_ref: "subject".into(), + child_session_id: "child-fresh".into(), + capability_token: "token-fresh".into(), + auth: auth(), + now: at(7), + }) + .unwrap(); + let fresh = store + .submit_review(SubmitMergeRequestReview { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + current_subject_ref: "subject".into(), + capability_token: "token-fresh".into(), + decision: ReviewDecision::Approve, + body: "fresh requirements approved".into(), + findings: vec![], + now: at(8), + }) + .unwrap(); + store + .complete(CompleteMergeRequest { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + operation_id: "merge-fresh".into(), + approval_event_id: fresh.event_id, + current_subject_ref: "subject".into(), + target_ref_before: "target-before".into(), + target_ref_after: "target-after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(9), + }) + .unwrap(); +} + +#[test] +fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { + let (dir, store) = fixture(); + open(&store); + let integration_approval = approve(&store, "subject", "token-integration"); + store + .complete(CompleteMergeRequest { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + operation_id: "merge".into(), + approval_event_id: integration_approval.event_id, + current_subject_ref: "subject".into(), + target_ref_before: "target-before".into(), + target_ref_after: "target-after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(5), + }) + .unwrap(); + let connection = Connection::open(dir.path().join("db")).unwrap(); + connection + .execute( + "INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO typed_ticket_event_attributes VALUES('W','T',1,'event_id','t2')", + [], + ) + .unwrap(); + drop(connection); + + store + .register_reviewer_child_session(RegisterReviewerChildSession { + workspace_id: "W".into(), + parent_runtime_id: "runtime".into(), + parent_worker_id: "coder".into(), + child_session_id: "child-post-merge".into(), + reviewer_profile: "builtin:reviewer".into(), + now: at(6), + }) + .unwrap(); + store + .request_review(RequestMergeRequestReview { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + ticket_item_revision: "t2".into(), + ticket_merge_request_subjects: vec![MergeRequestReviewSubject { + merge_request_id: "MR".into(), + subject_ref: "subject".into(), + }], + subject_ref: "subject".into(), + child_session_id: "child-post-merge".into(), + capability_token: "token-post-merge".into(), + auth: auth(), + now: at(7), + }) + .unwrap(); + let requirement_approval = store + .submit_review(SubmitMergeRequestReview { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + current_subject_ref: "subject".into(), + capability_token: "token-post-merge".into(), + decision: ReviewDecision::Approve, + body: "rescope requirements approved against merged result".into(), + findings: vec![], + now: at(8), + }) + .unwrap(); + store + .complete_ticket(CompleteTicket { + ticket_id: "T".into(), + operation_id: "ticket-complete-post-rescope".into(), + item_revision: "t2".into(), + merge_request_ids: vec!["MR".into()], + requirement_approval_event_id: requirement_approval.event_id, + auth: auth(), + now: at(9), + }) + .unwrap(); +} + +#[test] +fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() { + let (_dir, store) = fixture(); + open_for(&store, "MR", "R"); + open_for(&store, "MR-2", "R2"); + store + .register_reviewer_child_session(RegisterReviewerChildSession { + workspace_id: "W".into(), + parent_runtime_id: "runtime".into(), + parent_worker_id: "coder".into(), + child_session_id: "child-incomplete".into(), + reviewer_profile: "builtin:reviewer".into(), + now: at(3), + }) + .unwrap(); + let result = store.request_review(RequestMergeRequestReview { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + ticket_item_revision: "t".into(), + ticket_merge_request_subjects: vec![MergeRequestReviewSubject { + merge_request_id: "MR".into(), + subject_ref: "subject-one".into(), + }], + subject_ref: "subject-one".into(), + child_session_id: "child-incomplete".into(), + capability_token: "token-incomplete".into(), + auth: auth(), + now: at(4), + }); + assert!(matches!(result, Err(MergeRequestError::Conflict(_)))); +} + +#[test] +fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_completion() { + let (dir, store) = fixture(); + open_for(&store, "MR", "R"); + open_for(&store, "MR-2", "R2"); + let first = approve_for(&store, "MR", "R", "subject-one", "token-one"); + let second = approve_for(&store, "MR-2", "R2", "subject-two", "token-two"); + + store + .complete(CompleteMergeRequest { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + operation_id: "merge-one".into(), + approval_event_id: first.event_id, + current_subject_ref: "subject-one".into(), + target_ref_before: "target-one-before".into(), + target_ref_after: "target-one-after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth_repo("R"), + now: at(5), + }) + .unwrap(); + let connection = Connection::open(dir.path().join("db")).unwrap(); + let (state, assigned): (String, bool) = connection + .query_row( + "SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(state, "inprogress"); + assert!(assigned); + assert!(matches!( + store.complete_ticket(CompleteTicket { + ticket_id: "T".into(), + operation_id: "ticket-complete".into(), + item_revision: "t".into(), + merge_request_ids: vec!["MR".into(), "MR-2".into()], + requirement_approval_event_id: second.event_id.clone(), + auth: auth(), + now: at(6), + }), + Err(MergeRequestError::NotReady(_)) + )); + + store + .complete(CompleteMergeRequest { + merge_request_id: "MR-2".into(), + ticket_id: "T".into(), + operation_id: "merge-two".into(), + approval_event_id: second.event_id.clone(), + current_subject_ref: "subject-two".into(), + target_ref_before: "target-two-before".into(), + target_ref_after: "target-two-after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth_repo("R2"), + now: at(7), + }) + .unwrap(); + assert!(matches!( + store.complete_ticket(CompleteTicket { + ticket_id: "T".into(), + operation_id: "ticket-complete".into(), + item_revision: "stale".into(), + merge_request_ids: vec!["MR".into(), "MR-2".into()], + requirement_approval_event_id: second.event_id.clone(), + auth: auth(), + now: at(8), + }), + Err(MergeRequestError::Conflict(_)) + )); + assert!(matches!( + store.complete_ticket(CompleteTicket { + ticket_id: "T".into(), + operation_id: "ticket-complete-missing-attestation".into(), + item_revision: "t".into(), + merge_request_ids: vec!["MR".into(), "MR-2".into()], + requirement_approval_event_id: "missing-review".into(), + auth: auth(), + now: at(8), + }), + Err(MergeRequestError::NotReady(_)) + )); + assert!(matches!( + store.complete_ticket(CompleteTicket { + ticket_id: "T".into(), + operation_id: "ticket-complete".into(), + item_revision: "t".into(), + merge_request_ids: vec!["MR".into()], + requirement_approval_event_id: second.event_id.clone(), + auth: auth(), + now: at(8), + }), + Err(MergeRequestError::Conflict(_)) + )); + let completed = store + .complete_ticket(CompleteTicket { + ticket_id: "T".into(), + operation_id: "ticket-complete".into(), + item_revision: "t".into(), + merge_request_ids: vec!["MR-2".into(), "MR".into()], + requirement_approval_event_id: second.event_id.clone(), + auth: auth(), + now: at(9), + }) + .unwrap(); + let replay = store + .complete_ticket(CompleteTicket { + ticket_id: "T".into(), + operation_id: "ticket-complete".into(), + item_revision: "t".into(), + merge_request_ids: vec!["MR".into(), "MR-2".into()], + requirement_approval_event_id: second.event_id.clone(), + auth: auth(), + now: at(10), + }) + .unwrap(); + assert_eq!(replay, completed); +} diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index 8a48a0411..5cfe93f7d 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true [features] default = ["stream"] stream = ["dep:tokio"] -typescript = ["dep:ts-rs"] +typescript = ["dep:ts-rs", "json-schema"] json-schema = ["dep:schemars"] [dependencies] diff --git a/crates/protocol/examples/generate_typescript.rs b/crates/protocol/examples/generate_typescript.rs index b5b3fdb97..dd90ec0db 100644 --- a/crates/protocol/examples/generate_typescript.rs +++ b/crates/protocol/examples/generate_typescript.rs @@ -1,11 +1,38 @@ #[cfg(feature = "typescript")] fn main() -> Result<(), Box> { - let path = protocol::typescript::generated_typescript_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; + let check = std::env::args() + .skip(1) + .any(|argument| argument == "--check"); + let outputs = [ + ( + protocol::typescript::generated_typescript_path(), + protocol::typescript::generated_protocol_types(), + ), + ( + protocol::typescript::generated_runtime_validator_path(), + protocol::typescript::generated_protocol_validator(), + ), + ]; + + for (path, expected) in outputs { + if check { + let actual = std::fs::read_to_string(&path)?; + if actual != expected { + return Err(format!( + "generated protocol artifact is stale: {}; run without --check", + path.display() + ) + .into()); + } + println!("current {}", path.display()); + } else { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, expected)?; + println!("wrote {}", path.display()); + } } - std::fs::write(&path, protocol::typescript::generated_protocol_types())?; - println!("wrote {}", path.display()); Ok(()) } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index dc89f04f5..a9bcc7a5d 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -83,6 +83,7 @@ impl AuthenticatedInputSource { /// of an accepted id must retain the same command kind. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct WorkerCommandEnvelope { pub command_id: u64, } @@ -95,6 +96,7 @@ impl WorkerCommandEnvelope { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum WorkerCommandKind { Resume, @@ -106,6 +108,7 @@ pub enum WorkerCommandKind { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum WorkerCommandDisposition { Accepted, @@ -116,6 +119,7 @@ pub enum WorkerCommandDisposition { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct WorkerCommandAcknowledgement { pub command_id: u64, pub command: WorkerCommandKind, @@ -212,6 +216,7 @@ impl From for WorkerStateSnapshot { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "method", content = "params", rename_all = "snake_case")] pub enum Method { /// Durably accept typed input for immediate activation while the Worker is Idle. @@ -228,6 +233,7 @@ pub enum Method { /// Authenticated transport form of Submit. Trusted adapters replace /// public Submit before forwarding it to the Worker. #[cfg_attr(feature = "typescript", ts(skip))] + #[cfg_attr(feature = "json-schema", schemars(skip))] SubmitTracked { submission_request_id: String, input: Vec, @@ -245,6 +251,7 @@ pub enum Method { }, /// Authenticated transport form of Notify. #[cfg_attr(feature = "typescript", ts(skip))] + #[cfg_attr(feature = "json-schema", schemars(skip))] NotifyTracked { notification_request_id: String, message: String, @@ -344,6 +351,7 @@ pub enum Method { /// child Worker). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum WorkerEvent { /// Child finished one turn and is back to IDLE. @@ -685,6 +693,7 @@ impl Method { /// protocol subjects. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum InternalWorkerKind { SubWorker, @@ -694,6 +703,7 @@ pub enum InternalWorkerKind { /// Stable parent-owned lifecycle for one compaction run. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct CompactionLifecycle { pub schema_version: u32, pub compaction_id: String, @@ -715,6 +725,7 @@ pub struct CompactionLifecycle { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CompactionLifecycleState { Running, @@ -727,6 +738,7 @@ pub enum CompactionLifecycleState { /// `name` is display-only; `session_id` is the identity used by clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct InternalWorkerRef { pub session_id: String, pub name: String, @@ -739,6 +751,7 @@ pub struct InternalWorkerRef { /// `Event::InternalWorker` updates that raced with parent snapshot assembly. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct InternalWorkerSnapshot { pub worker: InternalWorkerRef, pub revision: u64, @@ -755,6 +768,7 @@ pub struct InternalWorkerSnapshot { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum ToolResultDisposition { #[default] @@ -768,6 +782,7 @@ pub enum ToolResultDisposition { /// Durable acceptance result for one idempotent Submit request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubmissionDisposition { Started, @@ -780,6 +795,7 @@ pub enum SubmissionDisposition { /// provenance remain in the session log and are intentionally not exposed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct PendingSubmissionSummary { pub submission_id: String, pub accepted_at_ms: u64, @@ -790,6 +806,7 @@ pub struct PendingSubmissionSummary { /// Revisioned session-owned FIFO projection used by snapshots and live events. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct PendingSubmissionsSnapshot { pub revision: u64, #[serde(default)] @@ -807,6 +824,7 @@ pub struct PendingSubmissionsSnapshot { /// to replay the durable log format. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SessionSnapshot { #[serde(default)] pub pending_submissions: PendingSubmissionsSnapshot, @@ -815,6 +833,7 @@ pub struct SessionSnapshot { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SessionEntryProvenance { HumanInput, @@ -829,6 +848,7 @@ pub enum SessionEntryProvenance { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SessionSnapshotEntry { /// Stable identity from durable history metadata, or a deterministic /// identity derived from the legacy segment and log position. @@ -844,6 +864,7 @@ pub struct SessionSnapshotEntry { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SessionSnapshotEntryData { UserInput { @@ -881,6 +902,7 @@ pub enum SessionSnapshotEntryData { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SessionMessageRole { User, @@ -889,6 +911,7 @@ pub enum SessionMessageRole { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SessionContentPart { Text { text: String }, @@ -897,6 +920,7 @@ pub enum SessionContentPart { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SessionToolAttachment { pub media_type: String, /// Base64-encoded durable attachment body. Public snapshots preserve the @@ -906,6 +930,7 @@ pub struct SessionToolAttachment { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum Event { /// Durable Submit acceptance. New submissions are accepted only while Idle, @@ -1233,6 +1258,7 @@ pub enum Event { /// human-readable. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct Alert { pub level: AlertLevel, pub source: AlertSource, @@ -1243,6 +1269,7 @@ pub struct Alert { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct MemoryWorkerEvent { pub worker: String, pub status: String, @@ -1257,6 +1284,7 @@ pub struct MemoryWorkerEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum AlertLevel { Warn, @@ -1265,6 +1293,7 @@ pub enum AlertLevel { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum AlertSource { Worker, @@ -1278,6 +1307,7 @@ pub enum AlertSource { /// Mirrors the completion prefix sigil: `@` → `File`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CompletionKind { File, @@ -1288,6 +1318,7 @@ pub enum CompletionKind { /// `value` is a path (file kind). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct CompletionEntry { pub value: String, #[serde(default)] @@ -1296,14 +1327,17 @@ pub struct CompletionEntry { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct RewindTargetId { #[cfg_attr(feature = "typescript", ts(type = "string"))] + #[cfg_attr(feature = "json-schema", schemars(with = "String"))] pub segment_id: uuid::Uuid, pub user_input_entry_index: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct RewindTarget { pub id: RewindTargetId, pub expected_head_entries: usize, @@ -1320,6 +1354,7 @@ pub struct RewindTarget { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct RewindSummary { pub truncated_to_entries: usize, pub discarded_entries: usize, @@ -1330,6 +1365,7 @@ pub struct RewindSummary { /// state only and are never appended to Worker history. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CommandStatus { Running, @@ -1341,6 +1377,7 @@ pub enum CommandStatus { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CommandStream { Stdout, @@ -1349,6 +1386,7 @@ pub enum CommandStream { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct CommandStreamSlice { pub start_offset: u64, pub end_offset: u64, @@ -1358,6 +1396,7 @@ pub struct CommandStreamSlice { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct CommandSnapshot { pub command_id: String, pub tool_call_id: Option, @@ -1372,6 +1411,7 @@ pub struct CommandSnapshot { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum CommandEvent { Started { @@ -1399,6 +1439,7 @@ pub enum CommandEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CompactionPhase { Preparing, @@ -1408,6 +1449,7 @@ pub enum CompactionPhase { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CompactionTrigger { Manual, @@ -1417,6 +1459,7 @@ pub enum CompactionTrigger { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct InFlightCompaction { pub phase: CompactionPhase, pub started_at_ms: u64, @@ -1432,6 +1475,7 @@ pub struct InFlightCompaction { /// entries. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct InFlightSnapshot { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocks: Vec, @@ -1453,6 +1497,7 @@ impl InFlightSnapshot { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum InFlightBlock { Text { @@ -1476,6 +1521,7 @@ pub enum InFlightBlock { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum InFlightToolCallState { #[default] @@ -1497,6 +1543,7 @@ impl InFlightToolCallState { /// their own view of the manifest. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct Greeting { pub worker_name: String, pub cwd: String, @@ -1518,6 +1565,7 @@ pub struct Greeting { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum WorkerStatus { #[default] @@ -1529,6 +1577,7 @@ pub enum WorkerStatus { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum TurnResult { Finished, @@ -1543,6 +1592,7 @@ pub enum TurnResult { /// following Turn entry, not by the marker itself. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum InvokeKind { /// `Method::Submit` — a user submission. @@ -1563,6 +1613,7 @@ pub enum InvokeKind { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum RunResult { Finished, @@ -1577,6 +1628,7 @@ pub enum RunResult { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum ErrorCode { AlreadyRunning, @@ -1599,6 +1651,7 @@ pub enum ErrorCode { /// A single allow or deny rule inside a scope configuration. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct ScopeRule { /// Target path. Must be absolute by the time a `Scope` is built from /// this rule — relative paths are resolved per-layer against the @@ -1629,6 +1682,7 @@ fn default_recursive() -> bool { /// path as presented through the Workdir, even when it aliases another target. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum SymlinkPolicy { #[default] @@ -1644,6 +1698,7 @@ pub enum SymlinkPolicy { /// below** the stated level. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum Permission { Read, diff --git a/crates/protocol/src/subscription.rs b/crates/protocol/src/subscription.rs index c26ba6eff..5043db5ff 100644 --- a/crates/protocol/src/subscription.rs +++ b/crates/protocol/src/subscription.rs @@ -13,6 +13,13 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::Event as WorkerProtocolEvent; pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 1; +/// Browser/runtime wire guardrails. These bounds are generated into the Workspace +/// Browser validator so aggregate limits remain part of the Rust-owned contract. +pub const MAX_SUBSCRIPTION_FRAME_JSON_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_SUBSCRIPTION_STRING_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_SUBSCRIPTION_COLLECTION_ITEMS: usize = 100_000; +pub const MAX_SUBSCRIPTION_VALUE_DEPTH: usize = 64; +pub const MAX_SUBSCRIPTION_VALUE_NODES: usize = 250_000; pub const MAX_CORRELATION_ID_BYTES: usize = 128; pub const MAX_RESOURCE_ID_BYTES: usize = 256; pub const MAX_WORKER_IDS_PER_SELECTOR: usize = 256; @@ -104,6 +111,7 @@ macro_rules! bounded_identifier { ($name:ident, $field:literal, $max:expr) => { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] + #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct $name(String); impl $name { @@ -203,6 +211,7 @@ fn validate_rejection_message(message: &str) -> Result<(), SubscriptionValidatio /// semantics is lexical, so equivalent selectors aggregate to one upstream key. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorkerIds(Vec); impl SubscriptionWorkerIds { @@ -257,6 +266,7 @@ impl<'de> Deserialize<'de> for SubscriptionWorkerIds { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "topic", rename_all = "snake_case")] pub enum EventSubscriptionSelector { RuntimeWorkers, @@ -308,6 +318,7 @@ impl EventSubscriptionSelector { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionFrame { pub protocol_version: u16, #[serde(flatten)] @@ -336,6 +347,7 @@ impl SubscriptionFrame { /// remain typed within each lane. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "frame", content = "message", rename_all = "snake_case")] pub enum SubscriptionFramePayload { Request(SubscriptionRequest), @@ -357,6 +369,7 @@ impl SubscriptionFramePayload { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorkerProtocolMethod { pub subscription_id: SubscriptionId, pub method: crate::Method, @@ -370,6 +383,7 @@ impl SubscriptionWorkerProtocolMethod { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "method", content = "params", rename_all = "snake_case")] pub enum SubscriptionRequest { SubscribeEvents { @@ -405,6 +419,7 @@ impl SubscriptionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "result", content = "payload", rename_all = "snake_case")] pub enum SubscriptionResponse { Subscribed { @@ -467,6 +482,7 @@ impl SubscriptionResponse { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubscriptionRejectionCode { InvalidRequest, @@ -480,6 +496,7 @@ pub enum SubscriptionRejectionCode { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubscriptionTerminationCode { Lagged, @@ -490,6 +507,7 @@ pub enum SubscriptionTerminationCode { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum SubscriptionEvent { Event { @@ -551,6 +569,7 @@ impl SubscriptionEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubscriptionWorkerState { Idle, @@ -564,8 +583,8 @@ pub enum SubscriptionWorkerState { /// `Unavailable` deliberately does not imply `Stopped`: it means the last observed /// lifecycle state is retained while the Runtime observation source is unavailable. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubscriptionWorkerAvailability { #[default] @@ -575,6 +594,7 @@ pub enum SubscriptionWorkerAvailability { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorkerWorkdirAttachment { /// Stable Worker-local routing alias. pub alias: String, @@ -582,6 +602,7 @@ pub struct SubscriptionWorkerWorkdirAttachment { /// omit this field and require `repository_key` from the Server projection. #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(skip))] + #[cfg_attr(feature = "json-schema", schemars(skip))] pub repository_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub repository_key: Option, @@ -610,6 +631,7 @@ impl SubscriptionWorkerWorkdirAttachment { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorker { pub worker_id: SubscriptionWorkerId, /// Set by the Workspace Server when projecting a Runtime-owned Worker to clients. @@ -674,12 +696,14 @@ impl SubscriptionWorker { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorkdir { pub working_directory_id: SubscriptionWorkdirId, /// Runtime-internal Repository id. Workspace-facing TypeScript contracts /// omit this field and require `repository_key` from the Server projection. #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(skip))] + #[cfg_attr(feature = "json-schema", schemars(skip))] pub repository_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub repository_key: Option, @@ -709,6 +733,7 @@ impl SubscriptionWorkdir { /// enter this DTO; Workspace Server must resolve the required Repository key. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct WorkspaceSubscriptionWorkdir { pub working_directory_id: SubscriptionWorkdirId, pub repository_key: String, @@ -726,6 +751,7 @@ impl WorkspaceSubscriptionWorkdir { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "topic", content = "data", rename_all = "snake_case")] pub enum SubscriptionSnapshot { Workers { @@ -789,6 +815,7 @@ impl SubscriptionSnapshot { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum SubscriptionEventPayload { WorkerUpserted { diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index 8ceb9ccd7..9318c3450 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use serde_json::{Map, Value}; use ts_rs::{Config, TS}; use crate::{ @@ -17,9 +18,13 @@ use crate::{ WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus, subscription::{ - EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame, - SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest, - SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, + EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES, + MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS, + MAX_SUBSCRIPTION_FRAME_JSON_BYTES, MAX_SUBSCRIPTION_STRING_BYTES, + MAX_SUBSCRIPTION_VALUE_DEPTH, MAX_SUBSCRIPTION_VALUE_NODES, MAX_WORKER_IDS_PER_SELECTOR, + SUBSCRIPTION_PROTOCOL_VERSION, SubscriptionEvent, SubscriptionEventPayload, + SubscriptionFrame, SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, + SubscriptionRequest, SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode, SubscriptionWorkdirId, SubscriptionWorker, SubscriptionWorkerAvailability, SubscriptionWorkerId, SubscriptionWorkerIds, SubscriptionWorkerProtocolMethod, SubscriptionWorkerState, @@ -28,12 +33,19 @@ use crate::{ }; const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts"; +const GENERATED_VALIDATOR_RELATIVE_PATH: &str = + "../../web/workspace/src/lib/generated/protocol-validator.ts"; /// Repository-relative destination for the Workspace web protocol bindings. pub fn generated_typescript_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_RELATIVE_PATH) } +/// Repository-relative destination for the generated Workspace Browser validator. +pub fn generated_runtime_validator_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_VALIDATOR_RELATIVE_PATH) +} + /// Render Workspace web TypeScript bindings for the Worker wire protocol DTOs. /// /// Rust DTOs in this crate remain the source of truth; this function is used by @@ -134,6 +146,293 @@ pub fn generated_protocol_types() -> String { normalize_typescript(output) } +/// Render the strict runtime validator paired with the generated declarations. +/// +/// `schemars` projects the reachable Serde shape from [`SubscriptionFrame`]. The +/// normalization closes every object and expands flattened enum payloads before +/// embedding that schema into the dependency-free Browser validator template. +pub fn generated_protocol_validator() -> String { + let mut schema = serde_json::to_value(schemars::schema_for!(SubscriptionFrame)) + .expect("SubscriptionFrame schema must serialize"); + close_object_shapes(&mut schema); + let schema = serde_json::to_string(&schema).expect("SubscriptionFrame schema must serialize"); + let fixtures = serde_json::to_string(&rust_serialized_subscription_frame_fixtures()) + .expect("SubscriptionFrame fixtures must serialize"); + include_str!("typescript_validator_template.ts") + .replace( + "__GENERATED_PREAMBLE__", + "// @generated by `cargo run -p protocol --example generate_typescript --features typescript`\n// Do not edit by hand. Rust DTO and validation authority lives in `crates/protocol`.", + ) + .replace( + "__PROTOCOL_VERSION__", + &SUBSCRIPTION_PROTOCOL_VERSION.to_string(), + ) + .replace( + "__MAX_FRAME_BYTES__", + &MAX_SUBSCRIPTION_FRAME_JSON_BYTES.to_string(), + ) + .replace( + "__MAX_STRING_BYTES__", + &MAX_SUBSCRIPTION_STRING_BYTES.to_string(), + ) + .replace( + "__MAX_COLLECTION_ITEMS__", + &MAX_SUBSCRIPTION_COLLECTION_ITEMS.to_string(), + ) + .replace( + "__MAX_VALUE_DEPTH__", + &MAX_SUBSCRIPTION_VALUE_DEPTH.to_string(), + ) + .replace( + "__MAX_VALUE_NODES__", + &MAX_SUBSCRIPTION_VALUE_NODES.to_string(), + ) + .replace( + "__MAX_CORRELATION_ID_BYTES__", + &MAX_CORRELATION_ID_BYTES.to_string(), + ) + .replace( + "__MAX_RESOURCE_ID_BYTES__", + &MAX_RESOURCE_ID_BYTES.to_string(), + ) + .replace( + "__MAX_WORKER_IDS_PER_SELECTOR__", + &MAX_WORKER_IDS_PER_SELECTOR.to_string(), + ) + .replace( + "__MAX_REJECTION_MESSAGE_BYTES__", + &MAX_REJECTION_MESSAGE_BYTES.to_string(), + ) + .replace("__SUBSCRIPTION_FRAME_SCHEMA__", &schema) + .replace("__RUST_SERIALIZED_FIXTURES__", &fixtures) +} + +fn rust_serialized_subscription_frame_fixtures() -> Vec { + let request_id = + SubscriptionRequestId::new("fixture-request").expect("fixture request id must be valid"); + let subscription_id = + SubscriptionId::new("fixture-subscription").expect("fixture subscription id must be valid"); + let worker_id = + SubscriptionWorkerId::new("fixture-worker").expect("fixture worker id must be valid"); + let worker = SubscriptionWorker { + worker_id: worker_id.clone(), + runtime_id: Some("fixture-runtime".to_string()), + resource_key: Some("fixture-worker-resource".to_string()), + availability: SubscriptionWorkerAvailability::default(), + subject_revision: 1, + worker_state: None, + state: SubscriptionWorkerState::Idle, + has_running_internal_workers: false, + workspace_id: Some("fixture-workspace".to_string()), + display_name: None, + profile: None, + workdir_attachments: Vec::new(), + }; + let subscribed = SubscriptionFrame::new(SubscriptionFramePayload::Response( + SubscriptionResponse::Subscribed { + request_id, + subscription_id: subscription_id.clone(), + selector: EventSubscriptionSelector::WorkspaceWorkers, + snapshot_revision: 1, + snapshot: SubscriptionSnapshot::Workers { + workers: vec![worker], + }, + }, + )); + let pending = + SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event { + subscription_id, + subject_revision: 2, + payload: SubscriptionEventPayload::WorkerProtocol { + worker_id, + event: Event::PendingSubmissionsChanged { + pending: PendingSubmissionsSnapshot::default(), + }, + }, + })); + for fixture in [&subscribed, &pending] { + fixture + .validate() + .expect("Rust-serialized Browser compatibility fixture must validate"); + } + vec![subscribed, pending] +} + +fn close_object_shapes(schema: &mut Value) { + let Value::Object(object) = schema else { + if let Value::Array(values) = schema { + for value in values { + close_object_shapes(value); + } + } + return; + }; + + expand_flattened_object(object); + for value in object.values_mut() { + close_object_shapes(value); + } + normalize_null_default(object); + normalize_integer_format(object); + require_serialized_defaults(object); + if object.get("type").and_then(Value::as_str) == Some("object") + && object.contains_key("properties") + { + object.insert("additionalProperties".to_string(), Value::Bool(false)); + } +} + +fn normalize_null_default(schema: &mut Map) { + if schema.get("default") != Some(&Value::Null) || schema.contains_key("anyOf") { + return; + } + let Some(schema_type) = schema.get_mut("type") else { + return; + }; + match schema_type { + Value::String(value) if value != "null" => { + *schema_type = Value::Array(vec![ + Value::String(value.clone()), + Value::String("null".into()), + ]); + } + Value::Array(values) if !values.iter().any(|value| value.as_str() == Some("null")) => { + values.push(Value::String("null".into())); + } + _ => {} + } +} + +fn normalize_integer_format(schema: &mut Map) { + let is_integer = match schema.get("type") { + Some(Value::String(value)) => value == "integer", + Some(Value::Array(values)) => values.iter().any(|value| value.as_str() == Some("integer")), + _ => false, + }; + if !is_integer { + return; + } + let Some(format) = schema.get("format").and_then(Value::as_str) else { + return; + }; + let (minimum, maximum) = match format { + "uint16" => (0.0, u16::MAX as f64), + "uint32" => (0.0, u32::MAX as f64), + "uint" | "uint64" => (0.0, 9_007_199_254_740_991.0), + "int32" => (i32::MIN as f64, i32::MAX as f64), + "int64" => (-9_007_199_254_740_991.0, 9_007_199_254_740_991.0), + other => panic!("unsupported integer format in SubscriptionFrame schema: {other}"), + }; + tighten_numeric_bound(schema, "minimum", minimum, f64::max); + tighten_numeric_bound(schema, "maximum", maximum, f64::min); +} + +fn tighten_numeric_bound( + schema: &mut Map, + keyword: &str, + contract_bound: f64, + tighten: fn(f64, f64) -> f64, +) { + let bound = schema + .get(keyword) + .and_then(Value::as_f64) + .map_or(contract_bound, |existing| tighten(existing, contract_bound)); + schema.insert( + keyword.to_string(), + Value::Number(serde_json::Number::from_f64(bound).expect("finite JSON Schema bound")), + ); +} + +fn require_serialized_defaults(schema: &mut Map) { + if schema.get("type").and_then(Value::as_str) != Some("object") { + return; + } + let Some(properties) = schema.get("properties").and_then(Value::as_object) else { + return; + }; + let defaulted = properties + .iter() + .filter(|(_, property)| { + property + .as_object() + .is_some_and(|value| value.contains_key("default")) + }) + .map(|(key, _)| Value::String(key.clone())) + .collect::>(); + if defaulted.is_empty() { + return; + } + let required = schema + .entry("required") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("object required must be an array"); + for key in defaulted { + if !required.contains(&key) { + required.push(key); + } + } +} + +/// Serde flatten is represented by schemars as object properties beside `oneOf`. +/// Move those common properties into each object alternative so standard JSON +/// Schema `additionalProperties: false` has the intended exact-shape semantics. +fn expand_flattened_object(object: &mut Map) { + if object.get("type").and_then(Value::as_str) != Some("object") + || !object.contains_key("properties") + { + return; + } + let Some(alternatives) = object.get("oneOf").and_then(Value::as_array) else { + return; + }; + if !alternatives.iter().all(|alternative| { + alternative + .as_object() + .and_then(|value| value.get("type")) + .and_then(Value::as_str) + == Some("object") + }) { + return; + } + + let base_properties = object + .remove("properties") + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + let base_required = object + .remove("required") + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default(); + object.remove("type"); + let alternatives = object + .get_mut("oneOf") + .and_then(Value::as_array_mut) + .expect("oneOf was checked above"); + for alternative in alternatives { + let alternative = alternative + .as_object_mut() + .expect("oneOf object alternative was checked above"); + let properties = alternative + .entry("properties") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .expect("object alternative properties must be an object"); + for (key, value) in &base_properties { + assert!( + properties.insert(key.clone(), value.clone()).is_none(), + "flattened schema property {key:?} collides with an enum payload" + ); + } + let required = alternative + .entry("required") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("object alternative required must be an array"); + required.extend(base_required.iter().cloned()); + } +} + fn normalize_typescript(output: String) -> String { let mut lines = output.lines().map(str::trim_end).collect::>(); while lines.last() == Some(&"") { @@ -169,6 +468,41 @@ mod tests { assert!(generated.contains("repository_key"), "{generated}"); } + #[test] + fn generated_schema_matches_serialized_defaults_and_rust_integer_widths() { + let mut schema = serde_json::to_value(schemars::schema_for!(SubscriptionFrame)).unwrap(); + close_object_shapes(&mut schema); + + let worker_required = schema + .pointer("/$defs/SubscriptionWorker/required") + .and_then(Value::as_array) + .unwrap(); + assert!(worker_required.contains(&Value::String("availability".into()))); + assert!(worker_required.contains(&Value::String("has_running_internal_workers".into()))); + + let pending_required = schema + .pointer("/$defs/PendingSubmissionsSnapshot/required") + .and_then(Value::as_array) + .unwrap(); + for field in ["notification_count", "head_id", "submissions"] { + assert!(pending_required.contains(&Value::String(field.into()))); + } + assert_eq!( + schema.pointer("/$defs/PendingSubmissionsSnapshot/properties/head_id/type"), + Some(&serde_json::json!(["string", "null"])) + ); + assert!( + generated_protocol_validator().contains("\"format\":\"uint32\",\"maximum\":4294967295") + ); + } + + #[test] + #[should_panic(expected = "unsupported integer format")] + fn generated_schema_rejects_unknown_integer_formats() { + let mut unsupported = serde_json::json!({"type": "integer", "format": "uint128"}); + close_object_shapes(&mut unsupported); + } + #[test] fn generated_protocol_types_are_current() { let expected = generated_protocol_types(); @@ -180,4 +514,18 @@ mod tests { "generated TypeScript protocol bindings are stale; run `cargo run -p protocol --example generate_typescript --features typescript`" ); } + + #[test] + fn generated_protocol_validator_is_current_and_strict() { + let expected = generated_protocol_validator(); + assert!(expected.contains("\"additionalProperties\":false")); + assert!(expected.contains("MAX_SUBSCRIPTION_FRAME_JSON_BYTES = 16777216")); + let path = generated_runtime_validator_path(); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + assert_eq!( + actual, expected, + "generated TypeScript protocol validator is stale; run `cargo run -p protocol --example generate_typescript --features typescript`" + ); + } } diff --git a/crates/protocol/src/typescript_validator_template.ts b/crates/protocol/src/typescript_validator_template.ts new file mode 100644 index 000000000..0bf0ec606 --- /dev/null +++ b/crates/protocol/src/typescript_validator_template.ts @@ -0,0 +1,434 @@ +__GENERATED_PREAMBLE__ + +import type { + EventSubscriptionSelector, + SubscriptionEventPayload, + SubscriptionFrame, + SubscriptionSnapshot, + SubscriptionWorker, + WorkspaceSubscriptionWorkdir, +} from './protocol'; + +export const SUBSCRIPTION_PROTOCOL_VERSION = __PROTOCOL_VERSION__; +export const MAX_SUBSCRIPTION_FRAME_JSON_BYTES = __MAX_FRAME_BYTES__; +export const MAX_SUBSCRIPTION_STRING_BYTES = __MAX_STRING_BYTES__; +export const MAX_SUBSCRIPTION_COLLECTION_ITEMS = __MAX_COLLECTION_ITEMS__; +export const MAX_SUBSCRIPTION_VALUE_DEPTH = __MAX_VALUE_DEPTH__; +export const MAX_SUBSCRIPTION_VALUE_NODES = __MAX_VALUE_NODES__; + +const MAX_CORRELATION_ID_BYTES = __MAX_CORRELATION_ID_BYTES__; +const MAX_RESOURCE_ID_BYTES = __MAX_RESOURCE_ID_BYTES__; +const MAX_WORKER_IDS_PER_SELECTOR = __MAX_WORKER_IDS_PER_SELECTOR__; +const MAX_REJECTION_MESSAGE_BYTES = __MAX_REJECTION_MESSAGE_BYTES__; +const subscriptionFrameSchema: unknown = __SUBSCRIPTION_FRAME_SCHEMA__; +export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = + __RUST_SERIALIZED_FIXTURES__; +const utf8 = new TextEncoder(); + +type JsonObject = Record; +type RejectionReason = + | 'invalid_transport' + | 'frame_too_large' + | 'invalid_json' + | 'aggregate_limit' + | 'invalid_structure' + | 'invalid_semantics'; + +export type SubscriptionFrameDecodeResult = + | { ok: true; value: SubscriptionFrame } + | { ok: false; reason: RejectionReason }; + +/** + * Decode an untrusted Workspace WebSocket message without reflecting its content. + * A successful result has passed aggregate limits, the exact generated JSON schema, + * and the Rust protocol's Browser-relevant semantic checks. + */ +export function decodeSubscriptionFrame(input: unknown): SubscriptionFrameDecodeResult { + if (typeof input !== 'string') return { ok: false, reason: 'invalid_transport' }; + if (utf8.encode(input).byteLength > MAX_SUBSCRIPTION_FRAME_JSON_BYTES) { + return { ok: false, reason: 'frame_too_large' }; + } + + let candidate: unknown; + try { + candidate = JSON.parse(input) as unknown; + } catch { + return { ok: false, reason: 'invalid_json' }; + } + if (!withinAggregateLimits(candidate)) return { ok: false, reason: 'aggregate_limit' }; + if (!validateSchema(candidate, subscriptionFrameSchema)) { + return { ok: false, reason: 'invalid_structure' }; + } + + // The assertion follows generated structural validation; it is not a parse-time cast. + const frame = candidate as SubscriptionFrame; + if (!validateInboundFrameSemantics(frame)) { + return { ok: false, reason: 'invalid_semantics' }; + } + return { ok: true, value: frame }; +} + +/** Validate stateful selector/payload consistency after the multiplexer resolves routing. */ +export function subscriptionFrameMatchesSelector( + frame: SubscriptionFrame, + selector: EventSubscriptionSelector, +): boolean { + if (frame.frame === 'response' && frame.message.result === 'subscribed') { + return selectorsEqual(frame.message.payload.selector, selector); + } + if (frame.frame === 'event' && frame.message.event === 'event') { + return eventPayloadMatchesSelector(frame.message.data.payload, selector); + } + return true; +} + +function withinAggregateLimits(root: unknown): boolean { + let nodes = 0; + const visit = (value: unknown, depth: number): boolean => { + nodes += 1; + if (nodes > MAX_SUBSCRIPTION_VALUE_NODES || depth > MAX_SUBSCRIPTION_VALUE_DEPTH) return false; + if (typeof value === 'string') { + return utf8.encode(value).byteLength <= MAX_SUBSCRIPTION_STRING_BYTES; + } + if (typeof value === 'number') { + return Number.isFinite(value) && (!Number.isInteger(value) || Number.isSafeInteger(value)); + } + if (value === null || typeof value === 'boolean') return true; + if (Array.isArray(value)) { + return ( + value.length <= MAX_SUBSCRIPTION_COLLECTION_ITEMS && + value.every((entry) => visit(entry, depth + 1)) + ); + } + if (!isObject(value)) return false; + const entries = Object.entries(value); + return ( + entries.length <= MAX_SUBSCRIPTION_COLLECTION_ITEMS && + entries.every( + ([key, entry]) => visit(key, depth + 1) && visit(entry, depth + 1), + ) + ); + }; + return visit(root, 0); +} + +function validateSchema(value: unknown, rawSchema: unknown): boolean { + if (rawSchema === true) return true; + if (rawSchema === false || !isObject(rawSchema)) return false; + const schema = rawSchema; + + if (typeof schema.$ref === 'string') { + const target = resolveLocalReference(schema.$ref); + if (target === undefined || !validateSchema(value, target)) return false; + } + if ('const' in schema && !jsonEqual(value, schema.const)) return false; + if (Array.isArray(schema.enum) && !schema.enum.some((entry) => jsonEqual(value, entry))) { + return false; + } + if (Array.isArray(schema.oneOf)) { + let matches = 0; + for (const alternative of schema.oneOf) { + if (validateSchema(value, alternative)) matches += 1; + } + if (matches !== 1) return false; + } + if (Array.isArray(schema.anyOf) && !schema.anyOf.some((entry) => validateSchema(value, entry))) { + return false; + } + + if (schema.type !== undefined && !matchesType(value, schema.type)) return false; + if (typeof value === 'number') { + if (schema.type === 'integer' && !Number.isSafeInteger(value)) return false; + if (typeof schema.minimum === 'number' && value < schema.minimum) return false; + if (typeof schema.maximum === 'number' && value > schema.maximum) return false; + } + if (typeof value === 'string') { + const length = [...value].length; + if (typeof schema.minLength === 'number' && length < schema.minLength) return false; + if (typeof schema.maxLength === 'number' && length > schema.maxLength) return false; + } + if (Array.isArray(value)) { + if (typeof schema.minItems === 'number' && value.length < schema.minItems) return false; + if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false; + if (schema.items !== undefined && !value.every((entry) => validateSchema(entry, schema.items))) { + return false; + } + } + if (isObject(value) && schema.properties !== undefined) { + if (!isObject(schema.properties)) return false; + const properties = schema.properties; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + if (typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(value, key)) return false; + } + } + for (const [key, entry] of Object.entries(value)) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + if (!validateSchema(entry, properties[key])) return false; + } else if (schema.additionalProperties === false) { + return false; + } else if (isObject(schema.additionalProperties) || typeof schema.additionalProperties === 'boolean') { + if (!validateSchema(entry, schema.additionalProperties)) return false; + } + } + } + return true; +} + +function resolveLocalReference(reference: string): unknown { + if (!reference.startsWith('#/')) return undefined; + let current: unknown = subscriptionFrameSchema; + for (const encoded of reference.slice(2).split('/')) { + if (!isObject(current)) return undefined; + const key = encoded.replaceAll('~1', '/').replaceAll('~0', '~'); + current = current[key]; + } + return current; +} + +function matchesType(value: unknown, rawType: unknown): boolean { + if (Array.isArray(rawType)) return rawType.some((entry) => matchesType(value, entry)); + switch (rawType) { + case 'null': + return value === null; + case 'boolean': + return typeof value === 'boolean'; + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + return typeof value === 'number' && Number.isSafeInteger(value); + case 'array': + return Array.isArray(value); + case 'object': + return isObject(value); + default: + return false; + } +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function jsonEqual(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && left.every((entry, index) => jsonEqual(entry, right[index])); + } + if (isObject(left) && isObject(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => Object.prototype.hasOwnProperty.call(right, key) && jsonEqual(left[key], right[key]), + ) + ); + } + return false; +} + +function validateInboundFrameSemantics(frame: SubscriptionFrame): boolean { + if (frame.protocol_version !== SUBSCRIPTION_PROTOCOL_VERSION) return false; + if (frame.frame === 'request' || frame.frame === 'worker_protocol') return false; + if (frame.frame === 'response') { + const message = frame.message; + if (!validateIdentifier(message.payload.request_id, MAX_CORRELATION_ID_BYTES)) return false; + if (message.result === 'subscribed') { + return ( + validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES) && + validateSelector(message.payload.selector) && + snapshotMatchesSelector(message.payload.snapshot, message.payload.selector) + ); + } + if (message.result === 'unsubscribed') { + return validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES); + } + return ( + (message.payload.subscription_id == null || + validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES)) && + validateBoundedMessage(message.payload.message) + ); + } + + const message = frame.message; + if (!validateIdentifier(message.data.subscription_id, MAX_CORRELATION_ID_BYTES)) return false; + if (message.event === 'subscription_closed') { + return validateBoundedMessage(message.data.message); + } + return validateEventPayload(message.data.payload); +} + +function validateSelector(selector: EventSubscriptionSelector): boolean { + if (selector.topic === 'worker_lifecycle') { + if ( + selector.worker_ids.length === 0 || + selector.worker_ids.length > MAX_WORKER_IDS_PER_SELECTOR + ) { + return false; + } + const ids = new Set(); + for (const workerId of selector.worker_ids) { + if (!validateIdentifier(workerId, MAX_RESOURCE_ID_BYTES) || ids.has(workerId)) return false; + ids.add(workerId); + } + return true; + } + if (selector.topic === 'worker_protocol') { + return ( + validateIdentifier(selector.worker_id, MAX_RESOURCE_ID_BYTES) && + (selector.runtime_id == null || + validateIdentifier(selector.runtime_id, MAX_RESOURCE_ID_BYTES)) + ); + } + return true; +} + +function snapshotMatchesSelector( + snapshot: SubscriptionSnapshot, + selector: EventSubscriptionSelector, +): boolean { + if (!validateSelector(selector)) return false; + if (selector.topic === 'runtime_workers' || selector.topic === 'workspace_workers') { + return snapshot.topic === 'workers' && validateWorkers(snapshot.data.workers); + } + if (selector.topic === 'worker_lifecycle') { + const selected = new Set(selector.worker_ids); + return ( + snapshot.topic === 'workers' && + validateWorkers(snapshot.data.workers) && + snapshot.data.workers.every((worker) => selected.has(worker.worker_id)) + ); + } + if (selector.topic === 'worker_protocol') { + return snapshot.topic === 'worker_protocol' && snapshot.data.worker_id === selector.worker_id; + } + return ( + snapshot.topic === 'workspace_workdirs' && + snapshot.data.workdirs.every(validateWorkspaceWorkdir) + ); +} + +function validateEventPayload(payload: SubscriptionEventPayload): boolean { + switch (payload.event) { + case 'worker_upserted': + return validateWorker(payload.data.worker); + case 'worker_removed': + return ( + validateIdentifier(payload.data.worker_id, MAX_RESOURCE_ID_BYTES) && + (payload.data.runtime_id == null || + validateIdentifier(payload.data.runtime_id, MAX_RESOURCE_ID_BYTES)) + ); + case 'worker_protocol': + return validateIdentifier(payload.data.worker_id, MAX_RESOURCE_ID_BYTES); + case 'workdir_upserted': + return validateWorkspaceWorkdir(payload.data.workdir); + case 'workdir_removed': + return validateIdentifier(payload.data.working_directory_id, MAX_RESOURCE_ID_BYTES); + } +} + +function eventPayloadMatchesSelector( + payload: SubscriptionEventPayload, + selector: EventSubscriptionSelector, +): boolean { + if (selector.topic === 'runtime_workers' || selector.topic === 'workspace_workers') { + return payload.event === 'worker_upserted' || payload.event === 'worker_removed'; + } + if (selector.topic === 'worker_lifecycle') { + return ( + (payload.event === 'worker_upserted' && selector.worker_ids.includes(payload.data.worker.worker_id)) || + (payload.event === 'worker_removed' && selector.worker_ids.includes(payload.data.worker_id)) + ); + } + if (selector.topic === 'worker_protocol') { + return payload.event === 'worker_protocol' && payload.data.worker_id === selector.worker_id; + } + return payload.event === 'workdir_upserted' || payload.event === 'workdir_removed'; +} + +function selectorsEqual( + left: EventSubscriptionSelector, + right: EventSubscriptionSelector, +): boolean { + if (left.topic !== right.topic) return false; + if (left.topic === 'worker_lifecycle' && right.topic === 'worker_lifecycle') { + const rightIds = new Set(right.worker_ids); + return left.worker_ids.length === rightIds.size && left.worker_ids.every((workerId) => rightIds.has(workerId)); + } + if (left.topic === 'worker_protocol' && right.topic === 'worker_protocol') { + return ( + left.worker_id === right.worker_id && + (left.runtime_id ?? null) === (right.runtime_id ?? null) + ); + } + return true; +} + +function validateWorkers(workers: SubscriptionWorker[]): boolean { + const identities = new Set(); + for (const worker of workers) { + const identity = `${worker.runtime_id ?? ''}\u0000${worker.worker_id}`; + if (!validateWorker(worker) || identities.has(identity)) return false; + identities.add(identity); + } + return true; +} + +function validateWorker(worker: SubscriptionWorker): boolean { + if (!validateIdentifier(worker.worker_id, MAX_RESOURCE_ID_BYTES)) return false; + if (worker.runtime_id != null && !validateIdentifier(worker.runtime_id, MAX_RESOURCE_ID_BYTES)) { + return false; + } + if (worker.resource_key != null && !validateIdentifier(worker.resource_key, MAX_RESOURCE_ID_BYTES)) { + return false; + } + const aliases = new Set(); + const workdirIds = new Set(); + for (const attachment of worker.workdir_attachments ?? []) { + if ( + !validateIdentifier(attachment.alias, MAX_RESOURCE_ID_BYTES) || + !validateIdentifier(attachment.working_directory_id, MAX_RESOURCE_ID_BYTES) || + attachment.repository_key == null || + !validateRepositoryKey(attachment.repository_key) || + aliases.has(attachment.alias) || + workdirIds.has(attachment.working_directory_id) + ) { + return false; + } + aliases.add(attachment.alias); + workdirIds.add(attachment.working_directory_id); + } + return true; +} + +function validateWorkspaceWorkdir(workdir: WorkspaceSubscriptionWorkdir): boolean { + return ( + validateIdentifier(workdir.working_directory_id, MAX_RESOURCE_ID_BYTES) && + validateRepositoryKey(workdir.repository_key) && + validateIdentifier(workdir.state, MAX_RESOURCE_ID_BYTES) + ); +} + +function validateIdentifier(value: string, maxBytes: number): boolean { + return ( + value.length > 0 && + utf8.encode(value).byteLength <= maxBytes && + value.trim() === value && + !/\p{Cc}/u.test(value) + ); +} + +function validateRepositoryKey(value: string): boolean { + return ( + utf8.encode(value).byteLength <= 64 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) + ); +} + +function validateBoundedMessage(value: string): boolean { + const bytes = utf8.encode(value).byteLength; + return bytes > 0 && bytes <= MAX_REJECTION_MESSAGE_BYTES; +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 0023404ed..4790c3f4e 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -245,6 +245,7 @@ impl_openapi_schema!( CancelTicketImplementationRequest, ClearTicketRoleAssignmentQuery, CompleteMergeRequestRequest, + CompleteTicketRequest, CreateTicketOrchestrationPlanRequest, CreateTicketRecordRequest, CreateTicketRelationRequest, @@ -279,6 +280,7 @@ impl_openapi_schema!( SubmitMergeRequestReviewRequest, TextResponse, TicketCloseRecordRequest, + TicketCompletionEvent, TicketDependencyCheckResponse, TicketDetail, TicketDoctorResponse, @@ -2616,20 +2618,20 @@ pub trait ServerApi { #[path] id: String, #[body] request: OpenMergeRequestRequest, ) -> Result; - #[get("/api/w/{workspace_id}/tickets/{id}/merge-request/readiness", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + #[get("/api/w/{workspace_id}/merge-requests/{id}/readiness", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn merge_request_readiness( &self, #[path] workspace_id: String, #[path] id: String, ) -> Result; - #[get("/api/w/{workspace_id}/tickets/{id}/merge-request/thread", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + #[get("/api/w/{workspace_id}/merge-requests/{id}/thread", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn merge_request_thread( &self, #[path] workspace_id: String, #[path] id: String, #[query] query: MergeRequestThreadQuery, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], browser_auth = true)] + #[post("/api/w/{workspace_id}/merge-requests/{id}/repair-source", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], browser_auth = true)] async fn merge_request_selector_repair( &self, #[extension] context: ServerRequestContext, @@ -2644,7 +2646,7 @@ pub trait ServerApi { #[path] workspace_id: String, #[body] request: RegisterReviewerChildSessionRequest, ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/review-capabilities", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + #[post("/api/w/{workspace_id}/merge-requests/{id}/review-capabilities", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] async fn merge_request_review_capability_register( &self, #[extension] context: ServerRequestContext, @@ -2652,14 +2654,14 @@ pub trait ServerApi { #[path] id: String, #[body] request: RegisterMergeRequestReviewCapabilityRequest, ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + #[post("/api/w/{workspace_id}/merge-requests/{id}/reviews", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] async fn merge_request_review_submit( &self, #[path] workspace_id: String, #[path] id: String, #[body] request: SubmitMergeRequestReviewRequest, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews/revoke", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + #[post("/api/w/{workspace_id}/merge-requests/{id}/reviews/revoke", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] async fn merge_request_review_revoke( &self, #[extension] context: ServerRequestContext, @@ -2667,7 +2669,7 @@ pub trait ServerApi { #[path] id: String, #[body] request: RevokeMergeRequestReviewRequest, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + #[post("/api/w/{workspace_id}/merge-requests/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] async fn merge_request_complete( &self, #[extension] context: ServerRequestContext, @@ -2675,6 +2677,14 @@ pub trait ServerApi { #[path] id: String, #[body] request: CompleteMergeRequestRequest, ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + async fn ticket_complete( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: CompleteTicketRequest, + ) -> Result; #[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_close_record( &self, @@ -5231,19 +5241,32 @@ impl From for WorkingDirectorySumma } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -#[serde(deny_unknown_fields)] -pub struct ExternalWorkdirGrantCreateRequest { - pub provider_instance_id: String, - pub display_name: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub ttl_seconds: u64, - pub read_only: bool, +#[serde(rename_all = "snake_case")] +pub enum ExternalWorkdirPermission { + #[default] + ReadOnly, + ReadWrite, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] +#[serde(deny_unknown_fields)] +pub struct ExternalWorkdirGrantCreateRequest { + pub provider_instance_id: String, + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub ttl_seconds: Option, + pub permission: ExternalWorkdirPermission, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] pub struct ExternalWorkdirGrantResponse { pub grant_id: String, @@ -5251,8 +5274,11 @@ pub struct ExternalWorkdirGrantResponse { pub working_directory_id: String, pub provider_instance_id: String, pub display_name: String, - pub permissions: String, - pub expires_at: String, + pub permission: ExternalWorkdirPermission, + /// `None` means the grant has no automatic expiry. It remains bounded by + /// explicit revoke, provider disconnect, and provider shutdown cleanup. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub generation: u64, pub status: String, @@ -5881,6 +5907,7 @@ pub struct TicketQueryItem { #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub unresolved_review_count: usize, pub evidence: TicketEvidenceSummary, + pub merge_requests: Vec, pub merge_request: Option, pub current_coder: Option, } @@ -5939,6 +5966,7 @@ pub struct TicketDetail { pub current_coder: Option, pub assignment_diagnostics: Vec, pub action_eligibility: TicketActionEligibility, + pub merge_requests: Vec, pub merge_request: Option, pub evidence: TicketEvidenceSummary, pub resolution: Option, @@ -6279,6 +6307,14 @@ pub struct MergeRequestWorkerIdentity { pub worker_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MergeRequestReviewSubject { + pub merge_request_id: String, + pub subject_ref: String, +} + macro_rules! merge_request_event { ($name:ident { $($field:ident : $ty:ty),* $(,)? }) => { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -6297,12 +6333,16 @@ macro_rules! merge_request_event { merge_request_event!(ReviewRequestedEvent { subject_ref: String, + ticket_item_revision: String, + ticket_merge_request_subjects: Vec, requested_by: MergeRequestWorkerIdentity, reviewer: MergeRequestWorkerIdentity, }); merge_request_event!(ReviewEvent { request_event_id: String, subject_ref: String, + ticket_item_revision: String, + ticket_merge_request_subjects: Vec, decision: ReviewDecision, body: String, findings: Vec, @@ -6487,6 +6527,7 @@ pub struct RegisterReviewerChildSessionRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RegisterMergeRequestReviewCapabilityRequest { + pub ticket_id: String, pub child_session_id: String, pub capability_token: String, } @@ -6513,6 +6554,27 @@ pub struct CompleteMergeRequestRequest { pub resolution: ConflictResolution, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CompleteTicketRequest { + pub operation_id: String, + pub item_revision: String, + pub merge_request_ids: Vec, + pub requirement_approval_event_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketCompletionEvent { + pub operation_id: String, + pub ticket_id: String, + pub item_revision: String, + pub merge_request_ids: Vec, + pub requirement_approval_event_id: String, + pub completed_by: MergeRequestWorkerIdentity, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RuntimeManagementApiError { @@ -9630,7 +9692,7 @@ mod tests { .iter() .filter(|operation| operation.operation_id.starts_with("ticket_")) .count(), - 37 + 38 ); assert_eq!( operations @@ -9660,7 +9722,7 @@ mod tests { ( "merge_request_complete", HttpMethod::Post, - "/api/w/{workspace_id}/tickets/{id}/merge-request/complete", + "/api/w/{workspace_id}/merge-requests/{id}/complete", ), ( "objective_ticket_unlink", @@ -11292,6 +11354,7 @@ mod openapi_artifact_tests { "merge_request_review_revoke", "merge_request_review_submit", "merge_request_reviewer_child_register", + "ticket_complete", "runtime_resource_fetch", "worker_control_cancel", "worker_control_input", diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 0ce6dc7e0..3e5199dc7 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -85,7 +85,7 @@ pub enum TicketError { }, #[error("ticket target repository `{0}` appears more than once")] DuplicateTargetRepository(String), - #[error("ticket requires exactly one read_write target, found {0}")] + #[error("ticket requires at least one read_write target, found {0}")] InvalidReadWriteTargetCount(usize), #[error("ticket target authority is unavailable")] TargetAuthorityUnavailable, @@ -649,7 +649,7 @@ fn resolve_ready_targets( .iter() .filter(|target| target.access == TicketTargetAccess::ReadWrite) .count(); - if read_write_count != 1 { + if read_write_count == 0 { return Err(TicketError::InvalidReadWriteTargetCount(read_write_count)); } let authority = authority.ok_or(TicketError::TargetAuthorityUnavailable)?; @@ -6009,45 +6009,48 @@ mod tests { Err(TicketError::DuplicateTargetRepository(repository)) if repository == "main" )); - for (name, targets, expected_count) in [ - ( - "No write target", - vec![ticket_target("docs", None, TicketTargetAccess::ReadOnly)], - 0, - ), - ( - "Multiple write targets", - vec![write_target("main"), write_target("docs")], - 2, - ), - ] { - let mut input = NewTicket::new(name); - input.targets = targets; - let ticket = backend.create(input).unwrap(); - let error = backend - .mark_ready( - TicketIdOrSlug::Id(ticket.id.clone()), - TicketMarkReady { - operation_key: format!("ready-{expected_count}"), - reason: None, - author: None, - intake_summary: None, - }, - ) - .unwrap_err(); - assert!(matches!( - error, - TicketError::InvalidReadWriteTargetCount(count) if count == expected_count - )); - let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); - assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Planning); - assert!( - unchanged - .events - .iter() - .all(|event| !event.attributes.contains_key("operation_key")) - ); - } + let mut no_write = NewTicket::new("No write target"); + no_write.targets = vec![ticket_target("docs", None, TicketTargetAccess::ReadOnly)]; + let ticket = backend.create(no_write).unwrap(); + let error = backend + .mark_ready( + TicketIdOrSlug::Id(ticket.id.clone()), + TicketMarkReady { + operation_key: "ready-no-write".into(), + reason: None, + author: None, + intake_summary: None, + }, + ) + .unwrap_err(); + assert!(matches!(error, TicketError::InvalidReadWriteTargetCount(0))); + let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); + assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Planning); + + let mut multiple_writes = NewTicket::new("Multiple write targets"); + multiple_writes.targets = vec![write_target("main"), write_target("docs")]; + let ticket = backend.create(multiple_writes).unwrap(); + let ready = backend + .mark_ready( + TicketIdOrSlug::Id(ticket.id), + TicketMarkReady { + operation_key: "ready-multiple-writes".into(), + reason: None, + author: None, + intake_summary: None, + }, + ) + .unwrap(); + assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!( + ready + .meta + .targets + .iter() + .filter(|target| target.access == TicketTargetAccess::ReadWrite) + .count(), + 2 + ); } #[test] diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index d04b6d97f..42ae82bd2 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -134,7 +134,7 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` const IMPLEMENTATION_REPORT_DESCRIPTION: &str = "Append a typed Ticket implementation_report event. `body` is Markdown."; const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \ -The backend atomically validates and normalizes every persisted repository target, requires exactly one \ +The backend atomically validates and normalizes every persisted repository target, requires at least one \ read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional."; const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \ The backend applies the same targets validation and lock as TicketMarkReady and commits the summary, \ diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs index fb42e1244..22ff45d83 100644 --- a/crates/workdir/src/external.rs +++ b/crates/workdir/src/external.rs @@ -22,9 +22,10 @@ pub const MAX_EXTERNAL_PROVIDER_FRAME_BYTES: usize = 8 * 1024 * 1024; const MAX_PROTOCOL_ID_BYTES: usize = 128; const MAX_EXTERNAL_PATH_BYTES: usize = 4 * 1024; const MAX_EXTERNAL_PATTERN_BYTES: usize = 4 * 1024; -const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000; +pub const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000; const MAX_EXTERNAL_GREP_CONTEXT: usize = 100; const MAX_EXTERNAL_SCOPE_RULES: usize = 64; +pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024; /// Fail-closed wire version for the External Workdir provider protocol. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] @@ -122,7 +123,10 @@ fn is_root_relative(path: &WorkdirPath) -> bool { }) } -fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<(), String> { +/// Validate the filesystem-only subset shared by read-only and read-write +/// External Workdirs. Capability enforcement remains a separate grant/session +/// boundary; command operations never enter the provider protocol. +fn validate_filesystem_operation(operation: &WorkdirSessionOperation) -> Result<(), String> { let path_is_valid = match operation { WorkdirSessionOperation::AuthorizeScope(request) => { is_root_relative(&request.path) @@ -165,13 +169,24 @@ fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<( && request.limit <= MAX_EXTERNAL_RESULT_ITEMS && request.offset <= 1_000_000 } - WorkdirSessionOperation::Write(_) - | WorkdirSessionOperation::Edit(_) - | WorkdirSessionOperation::CommandStart(_) + WorkdirSessionOperation::Write(request) => { + is_root_relative(&request.path) && request.content.len() <= MAX_EXTERNAL_WRITE_BYTES + } + WorkdirSessionOperation::Edit(request) => { + is_root_relative(&request.path) + && request.old_string.len() <= MAX_EXTERNAL_WRITE_BYTES + && request.new_string.len() <= MAX_EXTERNAL_WRITE_BYTES + && request + .old_string + .len() + .checked_add(request.new_string.len()) + .is_some_and(|size| size <= MAX_EXTERNAL_WRITE_BYTES) + } + WorkdirSessionOperation::CommandStart(_) | WorkdirSessionOperation::CommandStatus(_) | WorkdirSessionOperation::CommandOutput(_) | WorkdirSessionOperation::CommandCancel(_) => { - return Err("operation is not available to a read-only External Workdir".to_string()); + return Err("command operations are not available to an External Workdir".to_string()); } }; if path_is_valid { @@ -181,7 +196,7 @@ fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<( } } -/// Validated read-only subset of the shared Workdir operation contract. +/// Validated filesystem-only subset of the shared Workdir operation contract. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(transparent)] pub struct ExternalWorkdirOperation(WorkdirSessionOperation); @@ -200,7 +215,7 @@ impl TryFrom for ExternalWorkdirOperation { type Error = String; fn try_from(operation: WorkdirSessionOperation) -> Result { - validate_read_only_operation(&operation)?; + validate_filesystem_operation(&operation)?; Ok(Self(operation)) } } @@ -224,7 +239,7 @@ fn result_paths_fit<'a>(paths: impl IntoIterator) -> boo .is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES) } -fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<(), String> { +fn validate_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<(), String> { let result_is_valid = match result { WorkdirSessionOperationResult::AuthorizeScope | WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true, @@ -251,14 +266,19 @@ fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<( && result.match_count <= MAX_EXTERNAL_RESULT_ITEMS && result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS } - WorkdirSessionOperationResult::Write(_) - | WorkdirSessionOperationResult::Edit(_) - | WorkdirSessionOperationResult::CommandStart(_) + WorkdirSessionOperationResult::Write(result) => { + result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES + } + WorkdirSessionOperationResult::Edit(result) => { + result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES + && result.replacements <= MAX_EXTERNAL_RESULT_ITEMS + } + WorkdirSessionOperationResult::CommandStart(_) | WorkdirSessionOperationResult::CommandStatus(_) | WorkdirSessionOperationResult::CommandOutput(_) | WorkdirSessionOperationResult::CommandCancel => { return Err( - "operation result is not available to a read-only External Workdir".to_string(), + "command operation results are not available to an External Workdir".to_string(), ); } }; @@ -269,7 +289,7 @@ fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<( } } -/// Validated read-only subset of the shared Workdir operation result contract. +/// Validated filesystem-only subset of the shared Workdir operation result contract. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(transparent)] pub struct ExternalWorkdirOperationResult(WorkdirSessionOperationResult); @@ -288,7 +308,7 @@ impl TryFrom for ExternalWorkdirOperationResult { type Error = String; fn try_from(result: WorkdirSessionOperationResult) -> Result { - validate_read_only_result(&result)?; + validate_filesystem_result(&result)?; Ok(Self(result)) } } @@ -411,7 +431,10 @@ mod tests { use serde_json::Value; use super::*; - use crate::{CommandRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath}; + use crate::{ + CommandRequest, EditRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath, + WriteRequest, + }; fn assert_no_provider_authority(value: &Value) { match value { @@ -521,7 +544,7 @@ mod tests { } #[test] - fn read_only_protocol_rejects_host_paths_and_command_payloads() { + fn filesystem_protocol_rejects_host_paths_and_commands_but_allows_bounded_mutations() { let absolute = WorkdirSessionOperation::Read(ReadRequest { path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(), offset: 0, @@ -539,6 +562,33 @@ mod tests { tool_call_id: None, }); assert!(ExternalWorkdirOperation::try_from(command).is_err()); + + assert!( + ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Write(WriteRequest { + path: WorkdirPath::new("created.txt").unwrap(), + content: b"bounded".to_vec(), + expected_hash: None, + })) + .is_ok() + ); + assert!( + ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Edit(EditRequest { + path: WorkdirPath::new("item.txt").unwrap(), + old_string: "old".to_string(), + new_string: "new".to_string(), + replace_all: false, + expected_hash: [0; 32], + })) + .is_ok() + ); + assert!( + ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Write(WriteRequest { + path: WorkdirPath::new("large.txt").unwrap(), + content: vec![0; MAX_EXTERNAL_WRITE_BYTES + 1], + expected_hash: None, + })) + .is_err() + ); } #[test] diff --git a/crates/workdir/src/external_local_tests.rs b/crates/workdir/src/external_local_tests.rs index bd962bf89..943eb3dd5 100644 --- a/crates/workdir/src/external_local_tests.rs +++ b/crates/workdir/src/external_local_tests.rs @@ -79,6 +79,89 @@ async fn external_local_provider_is_strictly_read_only() { ); } +#[tokio::test] +async fn external_local_provider_read_write_is_filesystem_only() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("item.txt"), "original").unwrap(); + let session = LocalWorkdirSession::external_read_write( + Workdir::new("external-workdir-rw"), + root.path(), + BoundedReadLimits::new(4096, 1024).unwrap(), + ) + .unwrap(); + + assert_eq!( + session.capabilities(), + WorkdirSessionCapabilities::READ_WRITE + ); + assert!( + !session + .capabilities() + .supports(WorkdirSessionCapability::Command) + ); + + let observed = WorkdirSession::read( + &session, + ReadRequest { + path: WorkdirPath::new("item.txt").unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }, + ) + .await + .unwrap(); + WorkdirSession::edit( + &session, + EditRequest { + path: WorkdirPath::new("item.txt").unwrap(), + old_string: "original".to_string(), + new_string: "changed".to_string(), + replace_all: false, + expected_hash: observed.content_hash, + }, + ) + .await + .unwrap(); + WorkdirSession::write( + &session, + WriteRequest { + path: WorkdirPath::new("created.txt").unwrap(), + content: b"created".to_vec(), + expected_hash: None, + }, + ) + .await + .unwrap(); + + assert_eq!( + std::fs::read_to_string(root.path().join("item.txt")).unwrap(), + "changed" + ); + assert_eq!( + std::fs::read_to_string(root.path().join("created.txt")).unwrap(), + "created" + ); + let command = WorkdirSession::start_command( + &session, + CommandRequest { + command: "touch command-ran".to_string(), + timeout_secs: 1, + output_limit: 1024, + cwd: WorkdirPath::root(), + spill_dir: None, + tool_call_id: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!( + command, + WorkdirError::Unsupported(WorkdirSessionCapability::Command) + )); + assert!(!root.path().join("command-ran").exists()); +} + #[tokio::test] async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() { let root = tempfile::tempdir().unwrap(); @@ -228,6 +311,51 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() { assert!(matches!(error, WorkdirError::Denied(_))); } +#[cfg(unix)] +#[tokio::test] +async fn external_read_write_provider_rejects_symlink_mutation_escape() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), "secret").unwrap(); + symlink( + outside.path().join("secret.txt"), + root.path().join("outside.txt"), + ) + .unwrap(); + let session = LocalWorkdirSession::external_read_write( + Workdir::new("external-workdir-rw-symlink"), + root.path(), + BoundedReadLimits::new(4096, 1024).unwrap(), + ) + .unwrap(); + + let error = WorkdirSession::write( + &session, + WriteRequest { + path: WorkdirPath::new("outside.txt").unwrap(), + content: b"escaped".to_vec(), + expected_hash: None, + }, + ) + .await + .unwrap_err(); + assert!( + matches!( + error, + WorkdirError::SymlinkOutOfScope { .. } + | WorkdirError::OutOfScope(_) + | WorkdirError::Denied(_) + ), + "unexpected error: {error:?}" + ); + assert_eq!( + std::fs::read_to_string(outside.path().join("secret.txt")).unwrap(), + "secret" + ); +} + #[cfg(target_os = "linux")] #[tokio::test] async fn external_grep_honors_gitignore_above_a_nested_search_root() { @@ -446,6 +574,195 @@ async fn external_local_provider_keeps_pre_grant_root_when_path_changes_before_s assert!(!grep_after_symlink_swap.output.contains("secret.txt")); } +#[cfg(target_os = "linux")] +#[tokio::test] +async fn external_read_write_uses_pinned_root_for_conflict_and_commit() { + let parent = tempfile::tempdir().unwrap(); + let approved = parent.path().join("shared"); + let approved_original = parent.path().join("approved-original"); + std::fs::create_dir(&approved).unwrap(); + std::fs::write(approved.join("item.txt"), "approved").unwrap(); + let session = LocalWorkdirSession::external_read_write( + Workdir::new("external-workdir-pinned-rw"), + &approved, + BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + + std::fs::rename(&approved, &approved_original).unwrap(); + std::fs::create_dir(&approved).unwrap(); + + let conflict = WorkdirSession::write( + &session, + WriteRequest { + path: WorkdirPath::new("item.txt").unwrap(), + content: b"unexpected".to_vec(), + expected_hash: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!(conflict, WorkdirError::Conflict(_))); + assert_eq!( + std::fs::read_to_string(approved_original.join("item.txt")).unwrap(), + "approved" + ); + assert!(!approved.join("item.txt").exists()); + + let observed = WorkdirSession::read( + &session, + ReadRequest { + path: WorkdirPath::new("item.txt").unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }, + ) + .await + .unwrap(); + WorkdirSession::write( + &session, + WriteRequest { + path: WorkdirPath::new("item.txt").unwrap(), + content: b"updated".to_vec(), + expected_hash: Some(observed.content_hash), + }, + ) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(approved_original.join("item.txt")).unwrap(), + "updated" + ); + assert!(!approved.join("item.txt").exists()); +} + +#[tokio::test] +async fn external_read_write_rejects_oversized_preimages_and_edit_results_before_commit() { + let root = tempfile::tempdir().unwrap(); + let limit = crate::external::MAX_EXTERNAL_WRITE_BYTES; + let oversized_content = vec![b'x'; limit + 1]; + std::fs::write(root.path().join("oversized.txt"), &oversized_content).unwrap(); + std::fs::write(root.path().join("growth.txt"), vec![b'x'; limit]).unwrap(); + let high_replacement_content = vec![b'x'; crate::external::MAX_EXTERNAL_RESULT_ITEMS + 1]; + std::fs::write( + root.path().join("high-replacements.txt"), + &high_replacement_content, + ) + .unwrap(); + let session = LocalWorkdirSession::external_read_write( + Workdir::new("external-workdir-bounded-rw"), + root.path(), + BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + + let oversized_observed = WorkdirSession::read( + &session, + ReadRequest { + path: WorkdirPath::new("oversized.txt").unwrap(), + offset: 0, + limit: 1, + max_bytes: 1, + }, + ) + .await + .unwrap(); + let write_error = WorkdirSession::write( + &session, + WriteRequest { + path: WorkdirPath::new("oversized.txt").unwrap(), + content: b"small".to_vec(), + expected_hash: Some(oversized_observed.content_hash), + }, + ) + .await + .unwrap_err(); + assert!(matches!(write_error, WorkdirError::InvalidArgument(_))); + assert_eq!( + std::fs::read(root.path().join("oversized.txt")).unwrap(), + oversized_content + ); + let edit_error = WorkdirSession::edit( + &session, + EditRequest { + path: WorkdirPath::new("oversized.txt").unwrap(), + old_string: "x".to_string(), + new_string: "y".to_string(), + replace_all: false, + expected_hash: oversized_observed.content_hash, + }, + ) + .await + .unwrap_err(); + assert!(matches!(edit_error, WorkdirError::InvalidArgument(_))); + assert_eq!( + std::fs::read(root.path().join("oversized.txt")).unwrap(), + oversized_content + ); + + let growth_observed = WorkdirSession::read( + &session, + ReadRequest { + path: WorkdirPath::new("growth.txt").unwrap(), + offset: 0, + limit: 1, + max_bytes: 1, + }, + ) + .await + .unwrap(); + let growth_error = WorkdirSession::edit( + &session, + EditRequest { + path: WorkdirPath::new("growth.txt").unwrap(), + old_string: "x".to_string(), + new_string: "yy".to_string(), + replace_all: true, + expected_hash: growth_observed.content_hash, + }, + ) + .await + .unwrap_err(); + assert!(matches!(growth_error, WorkdirError::InvalidArgument(_))); + assert_eq!( + std::fs::read(root.path().join("growth.txt")).unwrap(), + vec![b'x'; limit] + ); + + let high_replacement_observed = WorkdirSession::read( + &session, + ReadRequest { + path: WorkdirPath::new("high-replacements.txt").unwrap(), + offset: 0, + limit: 1, + max_bytes: 1, + }, + ) + .await + .unwrap(); + let high_replacement_error = WorkdirSession::edit( + &session, + EditRequest { + path: WorkdirPath::new("high-replacements.txt").unwrap(), + old_string: "x".to_string(), + new_string: "y".to_string(), + replace_all: true, + expected_hash: high_replacement_observed.content_hash, + }, + ) + .await + .unwrap_err(); + assert!(matches!( + high_replacement_error, + WorkdirError::InvalidArgument(_) + )); + assert_eq!( + std::fs::read(root.path().join("high-replacements.txt")).unwrap(), + high_replacement_content + ); +} + #[cfg(target_os = "linux")] #[tokio::test] async fn external_local_provider_pins_the_approved_root_across_directory_replacement() { diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index 62b613d45..70a0b5a62 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -134,6 +134,14 @@ impl WorkdirSessionCapabilities { bits: Self::READ | Self::GLOB | Self::GREP, }; + /// Filesystem read/write authority without process execution. + /// + /// External Workdirs use this capability set so operator-approved file + /// mutation never implicitly grants command or shell authority. + pub const READ_WRITE: Self = Self { + bits: Self::READ | Self::WRITE | Self::EDIT | Self::GLOB | Self::GREP, + }; + pub const fn union(self, other: Self) -> Self { Self { bits: self.bits | other.bits, diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 819e61f52..2d67a3a12 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -347,6 +347,62 @@ impl fs_operation::FsAccessPolicy for ScopeAccess { } } + fn max_write_bytes(&self) -> Option { + self.reject_symlinks + .then_some(crate::external::MAX_EXTERNAL_WRITE_BYTES) + } + + fn max_edit_replacements(&self) -> Option { + self.reject_symlinks + .then_some(crate::external::MAX_EXTERNAL_RESULT_ITEMS) + } + + fn atomic_write_file( + &self, + _logical: &Path, + resolved: &Path, + content: &[u8], + mode: fs_operation::AtomicWriteMode, + ) -> std::io::Result<()> { + if !self.reject_symlinks { + use std::io::Write; + + let parent = resolved.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "file path has no parent") + })?; + std::fs::create_dir_all(parent)?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(content)?; + temporary.flush()?; + temporary.as_file().sync_all()?; + self.check_cancelled()?; + match mode { + fs_operation::AtomicWriteMode::CreateNew => temporary + .persist_noclobber(resolved) + .map_err(|error| error.error)?, + fs_operation::AtomicWriteMode::Replace => { + temporary.persist(resolved).map_err(|error| error.error)? + } + }; + return Ok(()); + } + let root = self.pinned_root.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Unsupported, + "External Workdir root handle is unavailable", + ) + })?; + let relative = resolved.strip_prefix(&self.root).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "path is outside provider root", + ) + })?; + fs_operation::atomic_write_beneath_no_symlinks_at(root, relative, content, mode, || { + self.check_cancelled() + }) + } + fn open_read_dir(&self, _logical: &Path, resolved: &Path) -> std::io::Result { if !self.reject_symlinks { return std::fs::read_dir(resolved); @@ -686,11 +742,6 @@ impl LocalWorkdirSession { /// Construct a strictly read-only provider session for an /// authority-assigned Workdir and an operator-selected local directory. - /// - /// The selected directory is atomically validated and pinned for the - /// session lifetime. A symlink selected as the root is rejected, and all - /// operation paths are confined below the pinned root without following - /// symbolic links. Read source and response sizes are provider-bounded. pub fn external_read_only( workdir: Workdir, directory: impl AsRef, @@ -700,20 +751,66 @@ impl LocalWorkdirSession { Self::external_read_only_pinned(workdir, root, read_limits) } - /// Construct a provider session from a root pinned before remote grant - /// creation, preventing any later pathname replacement from changing the - /// approved filesystem authority. + /// Construct a filesystem read/write provider session without command + /// authority for an operator-selected local directory. + pub fn external_read_write( + workdir: Workdir, + directory: impl AsRef, + read_limits: BoundedReadLimits, + ) -> Result { + let root = ExternalWorkdirRoot::pin(directory)?; + Self::external_read_write_pinned(workdir, root, read_limits) + } + + /// Construct a read-only provider session from a root pinned before remote + /// grant creation. pub fn external_read_only_pinned( workdir: Workdir, pinned: ExternalWorkdirRoot, read_limits: BoundedReadLimits, + ) -> Result { + Self::external_pinned( + workdir, + pinned, + read_limits, + Permission::Read, + WorkdirSessionCapabilities::READ_ONLY, + ) + } + + /// Construct a filesystem read/write provider session from a root pinned + /// before remote grant creation. Command execution remains unavailable. + pub fn external_read_write_pinned( + workdir: Workdir, + pinned: ExternalWorkdirRoot, + read_limits: BoundedReadLimits, + ) -> Result { + Self::external_pinned( + workdir, + pinned, + read_limits, + Permission::Write, + WorkdirSessionCapabilities::READ_WRITE, + ) + } + + /// The selected directory is atomically validated and pinned for the + /// session lifetime. A symlink selected as the root is rejected, and all + /// operation paths are confined below the pinned root without following + /// symbolic links. Read source and response sizes are provider-bounded. + fn external_pinned( + workdir: Workdir, + pinned: ExternalWorkdirRoot, + read_limits: BoundedReadLimits, + permission: Permission, + capabilities: WorkdirSessionCapabilities, ) -> Result { read_limits.validate().map_err(WorkdirError::from)?; let root = pinned.canonical_path; let scope = Scope::from_config(&ScopeConfig { allow: vec![ScopeRule { target: root.clone(), - permission: Permission::Read, + permission, recursive: true, symlink_policy: SymlinkPolicy::Resolved, }], @@ -721,7 +818,7 @@ impl LocalWorkdirSession { }) .map_err(|_| { WorkdirError::InvalidArgument( - "External Workdir read scope could not be established".to_string(), + "External Workdir filesystem scope could not be established".to_string(), ) })?; let scope = SharedScope::new(scope); @@ -731,7 +828,7 @@ impl LocalWorkdirSession { root, scope.clone(), scope, - WorkdirSessionCapabilities::READ_ONLY, + capabilities, BTreeMap::new(), Vec::new(), Some(pinned.handle), diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index d09869450..148309e32 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -208,7 +208,9 @@ impl WorkerExecutionHandle { /// Runtime hooks available to an execution backend for one Worker. #[cfg(feature = "ws-server")] type WorkerObservationPublisher = Arc< - dyn Fn(WorkerRef, protocol::Event) -> Result + Send + Sync, + dyn Fn(WorkerRef, protocol::Event) -> Result + + Send + + Sync, >; #[cfg(feature = "ws-server")] @@ -279,12 +281,10 @@ impl WorkerExecutionContext { .map_err(|_| RuntimeError::StatePoisoned)?; match &*state { WorkerExecutionPublicationState::Pending(_) => { - let WorkerExecutionPublicationState::Pending(events) = - std::mem::replace( - &mut *state, - WorkerExecutionPublicationState::Activating(VecDeque::new()), - ) - else { + let WorkerExecutionPublicationState::Pending(events) = std::mem::replace( + &mut *state, + WorkerExecutionPublicationState::Activating(VecDeque::new()), + ) else { unreachable!() }; *state = WorkerExecutionPublicationState::Activating(events); @@ -662,10 +662,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static { Vec::new() } - fn stop_worker_operation( - &self, - request: WorkerExecutionStopRequest, - ) -> WorkerExecutionResult { + fn stop_worker_operation(&self, request: WorkerExecutionStopRequest) -> WorkerExecutionResult { let Some(handle) = request.handle.as_ref() else { return WorkerExecutionResult::unsupported( WorkerExecutionOperation::Stop, diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index 682984fbc..8869484b4 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -134,7 +134,8 @@ impl FsRuntimeStore { #[cfg(test)] pub(crate) fn fail_next_worker_write(&self, fault: AtomicWriteFault) { - self.worker_write_fault.store(fault as u8, Ordering::Release); + self.worker_write_fault + .store(fault as u8, Ordering::Release); } pub(crate) fn open_or_create( @@ -967,12 +968,13 @@ fn migrate_worker_record( identity_path: &Path, ) -> Result { if source_schema_version == PREVIOUS_SCHEMA_VERSION { - let identity: WorkerIdentityRecord = serde_json::from_value(identity_document).map_err(|error| { - runtime_store_corrupt( - identity_path, - format!("decode schema-v8 Worker identity: {error}"), - ) - })?; + let identity: WorkerIdentityRecord = + serde_json::from_value(identity_document).map_err(|error| { + runtime_store_corrupt( + identity_path, + format!("decode schema-v8 Worker identity: {error}"), + ) + })?; identity.validate_for_schema(identity_path, PREVIOUS_SCHEMA_VERSION)?; let execution_path = identity_path.with_file_name(WORKER_EXECUTION_FILE); let execution: WorkerExecutionRecord = @@ -987,12 +989,13 @@ fn migrate_worker_record( mapping, identity_path, )?; - let identity: WorkerIdentityRecord = serde_json::from_value(documents.identity).map_err(|error| { - runtime_store_corrupt( - identity_path, - format!("decode validated schema-v8 Worker identity: {error}"), - ) - })?; + let identity: WorkerIdentityRecord = + serde_json::from_value(documents.identity).map_err(|error| { + runtime_store_corrupt( + identity_path, + format!("decode validated schema-v8 Worker identity: {error}"), + ) + })?; let execution_path = identity_path.with_file_name(WORKER_EXECUTION_FILE); let execution: WorkerExecutionRecord = serde_json::from_value(documents.execution).map_err(|error| { @@ -1965,8 +1968,9 @@ impl WorkerAggregateRecord { return Err(RuntimeError::StoreCorrupt { operation: "read Worker lifecycle reconciliation", path: path.to_path_buf(), - message: "Worker reconciliation status does not match the last settled status" - .to_string(), + message: + "Worker reconciliation status does not match the last settled status" + .to_string(), }); } WorkerExecutionRecord { @@ -2971,7 +2975,11 @@ mod tests { let committed: WorkerAggregateRecord = read_bounded_json(&aggregate_path, "read test Worker aggregate").unwrap(); assert_eq!(committed.created_at_ms, Some(2)); - assert!(!aggregate_path.with_file_name(WORKER_EXECUTION_FILE).exists()); + assert!( + !aggregate_path + .with_file_name(WORKER_EXECUTION_FILE) + .exists() + ); } #[test] @@ -3050,10 +3058,7 @@ mod tests { ) .unwrap(); - assert_eq!( - migrated.identity["schema_version"], - PREVIOUS_SCHEMA_VERSION - ); + assert_eq!(migrated.identity["schema_version"], PREVIOUS_SCHEMA_VERSION); assert!(migrated.identity.get("request").is_none()); assert!(migrated.identity.get("execution").is_none()); assert_eq!( @@ -3158,8 +3163,11 @@ mod tests { .contains_key(&worker_id) ); assert!(!worker_dir.join(WORKER_EXECUTION_FILE).exists()); - let aggregate: serde_json::Value = - read_json(&worker_dir.join(WORKER_FILE), "read migrated Worker aggregate").unwrap(); + let aggregate: serde_json::Value = read_json( + &worker_dir.join(WORKER_FILE), + "read migrated Worker aggregate", + ) + .unwrap(); assert_eq!(aggregate["schema_version"], SCHEMA_VERSION); assert_eq!(aggregate["execution_state"]["state"], "available"); assert_eq!( @@ -3192,10 +3200,7 @@ mod tests { ) .unwrap(); - assert_eq!( - migrated.identity["schema_version"], - PREVIOUS_SCHEMA_VERSION - ); + assert_eq!(migrated.identity["schema_version"], PREVIOUS_SCHEMA_VERSION); assert_eq!(migrated.identity["status"], "running"); assert!(migrated.identity.get("request").is_none()); assert!(migrated.identity.get("execution").is_none()); diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 49b4ef6e5..27d0754a3 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1775,13 +1775,12 @@ impl Runtime { } if !reconcile - && let Err(error) = - self.begin_restore_operation( - worker_ref, - operation_id, - operation_mode, - candidate_context.clone(), - ) + && let Err(error) = self.begin_restore_operation( + worker_ref, + operation_id, + operation_mode, + candidate_context.clone(), + ) { if matches!(error, RuntimeError::StoreCommitOutcomeUnknown { .. }) { return Ok(RuntimeWorkerRestoreResult::failed( @@ -1813,8 +1812,7 @@ impl Runtime { ); match commit { Ok(worker) => { - if let Err(error) = - backend.activate_restored_worker(operation_id, &handle) + if let Err(error) = backend.activate_restored_worker(operation_id, &handle) { tracing::error!( worker_id = %worker_ref.worker_id, @@ -1865,7 +1863,8 @@ impl Runtime { error = %error, "Worker restore commit failed and was rolled back" ); - if let Err(settle_error) = self.settle_restore_rollback(worker_ref) { + if let Err(settle_error) = self.settle_restore_rollback(worker_ref) + { tracing::error!( worker_id = %worker_ref.worker_id, error = %settle_error, @@ -1983,11 +1982,8 @@ impl Runtime { "failed to persist restore execution evidence; in-process reconciliation remains required" ); } - } else if let Err(retain_error) = - self.retain_restore_reconciliation_pending( - worker_ref, - candidate_context.clone(), - ) + } else if let Err(retain_error) = self + .retain_restore_reconciliation_pending(worker_ref, candidate_context.clone()) { tracing::error!( worker_id = %worker_ref.worker_id, @@ -2002,10 +1998,9 @@ impl Runtime { )) } WorkerExecutionSpawnResult::Errored(_result) => { - if let Err(retain_error) = self.retain_restore_reconciliation_pending( - worker_ref, - candidate_context.clone(), - ) { + if let Err(retain_error) = self + .retain_restore_reconciliation_pending(worker_ref, candidate_context.clone()) + { tracing::error!( worker_id = %worker_ref.worker_id, error = %retain_error, @@ -3110,7 +3105,11 @@ impl Runtime { message: "runtime has no execution backend".to_string(), } })?; - (backend, worker.execution_handle.clone(), pending.operation_id) + ( + backend, + worker.execution_handle.clone(), + pending.operation_id, + ) }; let result = backend.stop_worker_operation(WorkerExecutionStopRequest { operation_id, @@ -3725,37 +3724,36 @@ impl RuntimeState { None, None, ), - PersistedWorkerExecutionState::ReconciliationRequired(operation) => { - match operation { - PersistedWorkerLifecycleOperation::Restore(operation) => { - let restore_intent = - restore_intent_for_status(operation.last_settled_status); - ( - Some(operation.request), - true, - operation.binding.is_some(), - restore_intent, - Some(PendingWorkerRestore { - operation_id: operation.operation_id, - mode: operation.mode.into(), - last_settled_status: operation.last_settled_status, - }), - None, - ) - } - PersistedWorkerLifecycleOperation::Stop(operation) => ( + PersistedWorkerExecutionState::ReconciliationRequired(operation) => match operation + { + PersistedWorkerLifecycleOperation::Restore(operation) => { + let restore_intent = + restore_intent_for_status(operation.last_settled_status); + ( Some(operation.request), true, operation.binding.is_some(), - operation.restore_intent, - None, - Some(PendingWorkerStop { + restore_intent, + Some(PendingWorkerRestore { operation_id: operation.operation_id, + mode: operation.mode.into(), last_settled_status: operation.last_settled_status, }), - ), + None, + ) } - } + PersistedWorkerLifecycleOperation::Stop(operation) => ( + Some(operation.request), + true, + operation.binding.is_some(), + operation.restore_intent, + None, + Some(PendingWorkerStop { + operation_id: operation.operation_id, + last_settled_status: operation.last_settled_status, + }), + ), + }, PersistedWorkerExecutionState::Unavailable => ( None, false, @@ -4782,8 +4780,7 @@ impl WorkerRecord { last_settled_status: pending.last_settled_status, }), ) - } else if let (Some(pending), Some(request)) = - (self.pending_restore, self.request.clone()) + } else if let (Some(pending), Some(request)) = (self.pending_restore, self.request.clone()) { PersistedWorkerExecutionState::ReconciliationRequired( PersistedWorkerLifecycleOperation::Restore(PersistedWorkerRestoreOperation { @@ -7397,11 +7394,8 @@ mod tests { display_name: None, }; let backend = Arc::new(TestExecutionBackend::default()); - let runtime = Runtime::with_fs_store_and_execution_backend( - options.clone(), - backend.clone(), - ) - .unwrap(); + let runtime = + Runtime::with_fs_store_and_execution_backend(options.clone(), backend.clone()).unwrap(); runtime.store_config_bundle(test_bundle()).unwrap(); let worker = runtime .create_worker(task_request("reconcile pending stop")) @@ -7409,9 +7403,7 @@ mod tests { runtime_store(&runtime) .fail_next_worker_write(crate::fs_store::AtomicWriteFault::AfterRename); - let error = runtime - .stop_worker(&worker.worker_ref, None) - .unwrap_err(); + let error = runtime.stop_worker(&worker.worker_ref, None).unwrap_err(); assert!(matches!( error, RuntimeError::StoreCommitOutcomeUnknown { .. } @@ -7480,11 +7472,9 @@ mod tests { drop(reconciled); let restarted_backend = Arc::new(TestExecutionBackend::default()); - let restarted = Runtime::with_fs_store_and_execution_backend( - options, - restarted_backend.clone(), - ) - .unwrap(); + let restarted = + Runtime::with_fs_store_and_execution_backend(options, restarted_backend.clone()) + .unwrap(); assert_eq!(*restarted_backend.stop_count.lock().unwrap(), 0); assert_eq!(*restarted_backend.restore_count.lock().unwrap(), 0); assert_eq!( @@ -7645,19 +7635,20 @@ mod tests { let cursor_before = runtime .worker_observation_cursor_now(&worker.worker_ref) .unwrap(); - backend.restore_event.lock().unwrap().replace( - protocol::Event::WorkerState { + backend + .restore_event + .lock() + .unwrap() + .replace(protocol::Event::WorkerState { snapshot: protocol::WorkerStatus::Running.into(), - }, - ); + }); let gate = Arc::new(RestoreGate::default()); *backend.restore_gate.lock().unwrap() = Some(gate.clone()); let restoring_runtime = runtime.clone(); let restoring_ref = worker.worker_ref.clone(); - let restoring = std::thread::spawn(move || { - restoring_runtime.restore_worker_operation(&restoring_ref) - }); + let restoring = + std::thread::spawn(move || restoring_runtime.restore_worker_operation(&restoring_ref)); assert!(gate.wait_for_entered(1, std::time::Duration::from_secs(2))); let pending = runtime.worker_detail(&worker.worker_ref).unwrap(); @@ -8928,12 +8919,16 @@ mod tests { .create_worker(task_request("rolled back restore")) .unwrap(); runtime.stop_worker(&worker.worker_ref, None).unwrap(); - backend.restore_result.lock().unwrap().replace( - WorkerExecutionSpawnResult::RolledBack(WorkerExecutionResult::errored( - WorkerExecutionOperation::Restore, - "controller launch failed and cleanup completed", - )), - ); + backend + .restore_result + .lock() + .unwrap() + .replace(WorkerExecutionSpawnResult::RolledBack( + WorkerExecutionResult::errored( + WorkerExecutionOperation::Restore, + "controller launch failed and cleanup completed", + ), + )); let result = runtime .restore_worker_operation(&worker.worker_ref) @@ -9149,16 +9144,22 @@ mod tests { .unwrap(); let workers = restored.list_workers().unwrap(); assert_eq!(workers.len(), 1); - assert!(workers.iter().all(|summary| summary.worker_id != worker.worker_id)); + assert!( + workers + .iter() + .all(|summary| summary.worker_id != worker.worker_id) + ); assert!(matches!( restored.worker_detail(&worker.worker_ref), Err(RuntimeError::WorkerNotFound { .. }) )); - assert!(restored - .diagnostics() - .unwrap() - .iter() - .any(|diagnostic| diagnostic.code == "worker_record_unavailable")); + assert!( + restored + .diagnostics() + .unwrap() + .iter() + .any(|diagnostic| diagnostic.code == "worker_record_unavailable") + ); let restored_detail = restored .restore_worker(&restorable.worker_ref) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d84dad089..4f62c9199 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -1016,12 +1016,12 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { worker_ref: &WorkerRef, ) -> Result { - let Some(root) = self.worker_aggregate_root.as_ref() else { - return Err(session_store::RetainedSnapshotReadError::RetentionMissing); - }; + let aggregate_dir = self + .worker_aggregate_dir(worker_ref) + .map_err(|_| session_store::RetainedSnapshotReadError::RetentionMissing)?; let worker_name = Self::runtime_worker_name_for_ref(worker_ref); session_store::read_retained_session_snapshot( - &root.join(&worker_name), + &aggregate_dir, &worker_name, session_store::DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, ) @@ -1442,8 +1442,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { } let workspace_client = worker.workspace_client_handle(); - let run_dir = - self.worker_restore_run_dir(&request.worker_ref, request.operation_id)?; + let run_dir = self.worker_restore_run_dir(&request.worker_ref, request.operation_id)?; let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id); let started = PreparedWorker::new( worker, @@ -1483,11 +1482,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { workspace_id: Option<&str>, handle: &WorkerHandle, ) { - self.observation_hub.register( - worker_ref.clone(), - workspace_id.map(str::to_string), - handle, - ); + self.observation_hub + .register(worker_ref.clone(), workspace_id.map(str::to_string), handle); } } @@ -2953,13 +2949,10 @@ where self.send_method(WorkerExecutionOperation::ProtocolMethod, worker, method) } - fn stop_worker_operation( - &self, - request: WorkerExecutionStopRequest, - ) -> WorkerExecutionResult { - let handle = request.handle.unwrap_or_else(|| { - WorkerExecutionHandle::new(request.worker_ref, self.backend_id()) - }); + fn stop_worker_operation(&self, request: WorkerExecutionStopRequest) -> WorkerExecutionResult { + let handle = request + .handle + .unwrap_or_else(|| WorkerExecutionHandle::new(request.worker_ref, self.backend_id())); self.stop_worker(&handle) } @@ -3135,11 +3128,15 @@ mod tests { use crate::working_directory::RuntimeGitMaterializer; use agen::Engine; use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; - use agen::llm_client::{ClientError, LlmClient, Request}; + use agen::llm_client::{ClientError, LlmClient, Request, RequestConfig}; use async_trait::async_trait; use futures::{Stream, StreamExt}; use manifest::{Scope, WorkerManifest}; - use session_store::{LogEntry, WorkerMetadataStore}; + use session_store::{ + LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, + LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, + Store, WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, + }; #[test] fn production_source_has_no_repository_derived_runtime_store() { @@ -3155,6 +3152,217 @@ mod tests { } } + fn persisted_files(root: &Path) -> BTreeMap> { + fn collect(root: &Path, current: &Path, files: &mut BTreeMap>) { + if !current.exists() { + return; + } + for entry in fs::read_dir(current).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + collect(root, &path, files); + } else { + files.insert( + path.strip_prefix(root).unwrap().to_path_buf(), + fs::read(path).unwrap(), + ); + } + } + } + + let mut files = BTreeMap::new(); + collect(root, root, &mut files); + files + } + + fn write_retained_profile_worker( + runtime_store_dir: &Path, + worker_ref: &WorkerRef, + ) -> (session_store::SessionId, session_store::SegmentId, String) { + let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(worker_ref); + let aggregate_dir = runtime_store_dir + .join("workers") + .join(worker_ref.worker_id.to_string()); + let aggregate = WorkerAggregateStore::new(&aggregate_dir, &worker_name).unwrap(); + let session_id = session_store::new_session_id(); + let segment_id = session_store::new_segment_id(); + aggregate + .write(&WorkerMetadata::new( + &worker_name, + Some(WorkerActiveSegmentRef::active_segment( + session_id, segment_id, + )), + )) + .unwrap(); + let entry_id = LoggedSessionHistoryEntryId::new(); + let expected_entry_id = entry_id.0.clone(); + let log = [LogEntry::AnnotatedSegmentStart { + ts: 17, + session_id, + system_prompt: None, + config: RequestConfig::default(), + history: vec![LoggedHistoryEntry { + item: LoggedItem::Message { + role: LoggedRole::Assistant, + content: vec![LoggedContentPart::Text { + text: "retained response".to_string(), + }], + }, + metadata: LoggedSessionHistoryMetadata { + entry_id, + origin: LoggedSessionHistoryOrigin::LegacyUnknown, + derivation: None, + }, + }], + forked_from: None, + compacted_from: None, + }]; + WorkerSessionStore::new(aggregate_dir.join("session")) + .unwrap() + .create_segment(session_id, segment_id, &log) + .unwrap(); + (session_id, segment_id, expected_entry_id) + } + + #[test] + fn profile_factory_reads_retained_session_from_canonical_worker_aggregate() { + let root = tempfile::tempdir().unwrap(); + let runtime_store_dir = root.path().join("runtime"); + let worker_ref = WorkerRef::new(WorkerId::now_v7()); + let (session_id, segment_id, entry_id) = + write_retained_profile_worker(&runtime_store_dir, &worker_ref); + let canonical_dir = runtime_store_dir + .join("workers") + .join(worker_ref.worker_id.to_string()); + let legacy_prefixed_dir = runtime_store_dir.join("workers").join( + ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref), + ); + assert!(!legacy_prefixed_dir.exists()); + let files_before = persisted_files(&runtime_store_dir); + let backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(root.path().join("profiles")) + .with_runtime_store_dir(&runtime_store_dir), + ) + .unwrap(); + + let retained = backend.worker_session(WorkerSessionObservationRequest { + worker_ref: worker_ref.clone(), + }); + + let runtime_api::WorkerSessionAvailability::RetainedSnapshot { identity, snapshot } = + retained + else { + panic!("canonical aggregate must produce a retained snapshot"); + }; + assert_eq!(identity.session_id, session_id.to_string()); + assert_eq!(identity.segment_id, segment_id.to_string()); + assert_eq!(identity.entry_count, 1); + assert_eq!(snapshot.entries.len(), 1); + assert_eq!(snapshot.entries[0].entry_id, entry_id); + assert_eq!( + snapshot.entries[0].data, + protocol::SessionSnapshotEntryData::Message { + role: protocol::SessionMessageRole::Assistant, + content: vec![protocol::SessionContentPart::Text { + text: "retained response".to_string(), + }], + } + ); + assert!(canonical_dir.is_dir()); + assert!(!legacy_prefixed_dir.exists()); + assert_eq!(persisted_files(&runtime_store_dir), files_before); + assert!(backend.workers.lock().unwrap().is_empty()); + assert!( + backend + .factory + .observation_hub + .workers + .lock() + .unwrap() + .is_empty() + ); + } + + #[test] + fn profile_factory_retained_read_keeps_missing_aggregate_typed_and_absent() { + let root = tempfile::tempdir().unwrap(); + let runtime_store_dir = root.path().join("runtime"); + let worker_ref = WorkerRef::new(WorkerId::now_v7()); + let unconfigured_backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(root.path().join("unconfigured-profiles")), + ) + .unwrap(); + assert!(matches!( + unconfigured_backend.worker_session(WorkerSessionObservationRequest { + worker_ref: worker_ref.clone(), + }), + runtime_api::WorkerSessionAvailability::Unavailable { + reason: runtime_api::WorkerSessionUnavailableReason::RetentionMissing, + .. + } + )); + assert!(!root.path().join("unconfigured-profiles").exists()); + assert!(unconfigured_backend.workers.lock().unwrap().is_empty()); + + let canonical_dir = runtime_store_dir + .join("workers") + .join(worker_ref.worker_id.to_string()); + let backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(root.path().join("profiles")) + .with_runtime_store_dir(&runtime_store_dir), + ) + .unwrap(); + + let unavailable = backend.worker_session(WorkerSessionObservationRequest { + worker_ref: worker_ref.clone(), + }); + + assert!(matches!( + unavailable, + runtime_api::WorkerSessionAvailability::Unavailable { + reason: runtime_api::WorkerSessionUnavailableReason::RetentionMissing, + .. + } + )); + assert!(!canonical_dir.exists()); + assert!(!runtime_store_dir.exists()); + assert!(backend.workers.lock().unwrap().is_empty()); + } + + #[test] + fn profile_factory_retained_read_validates_metadata_worker_identity() { + let root = tempfile::tempdir().unwrap(); + let runtime_store_dir = root.path().join("runtime"); + let worker_ref = WorkerRef::new(WorkerId::now_v7()); + let aggregate_dir = runtime_store_dir + .join("workers") + .join(worker_ref.worker_id.to_string()); + fs::create_dir_all(&aggregate_dir).unwrap(); + fs::write( + aggregate_dir.join("metadata.json"), + serde_json::to_vec(&WorkerMetadata::new("different-worker", None)).unwrap(), + ) + .unwrap(); + let files_before = persisted_files(&runtime_store_dir); + let backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(root.path().join("profiles")) + .with_runtime_store_dir(&runtime_store_dir), + ) + .unwrap(); + + let unavailable = backend.worker_session(WorkerSessionObservationRequest { worker_ref }); + + assert!(matches!( + unavailable, + runtime_api::WorkerSessionAvailability::Unavailable { + reason: runtime_api::WorkerSessionUnavailableReason::CorruptLog, + ref message, + } if message == "retained session log is corrupt" + )); + assert_eq!(persisted_files(&runtime_store_dir), files_before); + assert!(backend.workers.lock().unwrap().is_empty()); + } + #[test] fn restored_router_uses_workspace_proxy_for_logical_only_attachment() { let root = tempfile::tempdir().unwrap(); @@ -3650,9 +3858,7 @@ mod tests { worker_ref, Arc::new(|worker_ref, payload| { Ok(crate::observation::WorkerObservationEvent::new( - 1, - worker_ref, - payload, + 1, worker_ref, payload, )) }), ) @@ -4679,11 +4885,9 @@ mod tests { observed_workspace_clients: observed_workspace_clients.clone(), }; let backend = Arc::new(WorkerRuntimeExecutionBackend::new(factory).unwrap()); - let runtime = EmbeddedRuntime::with_execution_backend( - RuntimeOptions::default(), - backend.clone(), - ) - .unwrap(); + let runtime = + EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone()) + .unwrap(); runtime.store_config_bundle(test_bundle()).unwrap(); let request = create_request("idempotent restore reconciliation"); let worker = runtime.create_worker(request.clone()).unwrap(); diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index 1752a42e3..410fa9bb6 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -28,12 +28,13 @@ fn workflow_instruction() -> FeatureInstructionDeclaration { .expect("static Merge Request workflow instruction declaration is valid") } -const ALL_KINDS: [Kind; 5] = [ +const ALL_KINDS: [Kind; 6] = [ Kind::Show, Kind::Open, Kind::Review, Kind::Readiness, Kind::Complete, + Kind::CompleteTicket, ]; #[derive(Clone, Copy)] @@ -42,6 +43,7 @@ enum Kind { Readiness, Open, Complete, + CompleteTicket, Review, } #[derive(Clone)] @@ -50,8 +52,8 @@ struct MergeRequestTool { kind: Kind, } #[derive(Debug, Deserialize, JsonSchema)] -struct TicketInput { - ticket: String, +struct MergeRequestInput { + merge_request_id: String, } #[derive(Debug, Deserialize, JsonSchema)] struct OpenMergeRequestInput { @@ -64,7 +66,7 @@ struct OpenMergeRequestInput { } #[derive(Debug, Deserialize, JsonSchema)] struct CompleteMergeRequestInput { - ticket: String, + merge_request_id: String, operation_id: String, approval_event_id: String, target_ref_before: String, @@ -73,6 +75,14 @@ struct CompleteMergeRequestInput { resolution: MergeResolutionInput, } #[derive(Debug, Deserialize, JsonSchema)] +struct CompleteTicketInput { + ticket: String, + operation_id: String, + item_revision: String, + merge_request_ids: Vec, + requirement_approval_event_id: String, +} +#[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum MergeStrategyInput { FastForward, @@ -110,16 +120,6 @@ struct ReviewFindingInput { line: Option, body: String, } -#[derive(Debug, Deserialize)] -struct TicketMergeRequestProjection { - merge_request: Option, -} - -#[derive(Debug, Deserialize)] -struct TicketMergeRequestReference { - merge_request_id: String, -} - impl Kind { fn enabled(self, config: MergeRequestFeatureConfig) -> bool { match self { @@ -127,7 +127,7 @@ impl Kind { Self::Open => config.open, Self::Review => config.review, Self::Readiness => config.readiness_check, - Self::Complete => config.complete, + Self::Complete | Self::CompleteTicket => config.complete, } } @@ -137,14 +137,16 @@ impl Kind { Self::Readiness => "CheckMergeRequestReadiness", Self::Open => "OpenMergeRequest", Self::Complete => "CompleteMergeRequest", + Self::CompleteTicket => "CompleteTicket", Self::Review => "ReviewMergeRequest", } } fn schema(self) -> serde_json::Value { match self { - Self::Show | Self::Readiness => json!(schemars::schema_for!(TicketInput)), + Self::Show | Self::Readiness => json!(schemars::schema_for!(MergeRequestInput)), Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)), Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)), + Self::CompleteTicket => json!(schemars::schema_for!(CompleteTicketInput)), Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)), } } @@ -156,17 +158,20 @@ impl Tool for MergeRequestTool { ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into()) })?; if matches!(self.kind, Kind::Show) { - let value: TicketInput = parse(input)?; - nonempty(&value.ticket)?; - return self.show_current_merge_request(ws, &value.ticket); + let value: MergeRequestInput = parse(input)?; + nonempty_named("merge_request_id", &value.merge_request_id)?; + return self.show_merge_request(ws, &value.merge_request_id); } let (method, path, body) = match self.kind { Kind::Readiness => { - let v: TicketInput = parse(input)?; - nonempty(&v.ticket)?; + let v: MergeRequestInput = parse(input)?; + nonempty_named("merge_request_id", &v.merge_request_id)?; ( WorkspaceRequestMethod::Get, - format!("/api/w/{ws}/tickets/{}/merge-request/readiness", v.ticket), + format!( + "/api/w/{ws}/merge-requests/{}/readiness", + encode_path_segment(&v.merge_request_id) + ), None, ) } @@ -184,15 +189,35 @@ impl Tool for MergeRequestTool { } Kind::Complete => { let v: CompleteMergeRequestInput = parse(input)?; - nonempty(&v.ticket)?; + nonempty_named("merge_request_id", &v.merge_request_id)?; ( WorkspaceRequestMethod::Post, - format!("/api/w/{ws}/tickets/{}/merge-request/complete", v.ticket), + format!( + "/api/w/{ws}/merge-requests/{}/complete", + encode_path_segment(&v.merge_request_id) + ), Some( json!({"operation_id":v.operation_id,"approval_event_id":v.approval_event_id,"target_ref_before":v.target_ref_before,"target_ref_after":v.target_ref_after,"strategy":match v.strategy{MergeStrategyInput::FastForward=>"fast_forward",MergeStrategyInput::Merge=>"merge"},"resolution":match v.resolution{MergeResolutionInput::None=>"none",MergeResolutionInput::Clean=>"clean",MergeResolutionInput::ConflictsResolved=>"conflicts_resolved"}}), ), ) } + Kind::CompleteTicket => { + let v: CompleteTicketInput = parse(input)?; + nonempty_named("ticket", &v.ticket)?; + ( + WorkspaceRequestMethod::Post, + format!( + "/api/w/{ws}/tickets/{}/complete", + encode_path_segment(&v.ticket) + ), + Some(json!({ + "operation_id": v.operation_id, + "item_revision": v.item_revision, + "merge_request_ids": v.merge_request_ids, + "requirement_approval_event_id": v.requirement_approval_event_id, + })), + ) + } Kind::Review => { let v: ReviewMergeRequestInput = parse(input)?; let ctx = self.client.reviewer_context().ok_or_else(|| { @@ -203,8 +228,8 @@ impl Tool for MergeRequestTool { ( WorkspaceRequestMethod::Post, format!( - "/api/w/{ws}/tickets/{}/merge-request/reviews", - ctx.ticket_id + "/api/w/{ws}/merge-requests/{}/reviews", + encode_path_segment(&ctx.merge_request_id) ), Some( json!({"decision":match v.decision{ReviewDecisionInput::Approve=>"approve",ReviewDecisionInput::RequestChanges=>"request_changes"},"body":v.body,"findings":v.findings.into_iter().map(|f|json!({"severity":f.severity,"code":f.code,"path":f.path,"line":f.line,"body":f.body})).collect::>() }), @@ -235,35 +260,12 @@ impl Tool for MergeRequestTool { } impl MergeRequestTool { - fn show_current_merge_request( + fn show_merge_request( &self, workspace_id: &str, - ticket: &str, + merge_request_id: &str, ) -> Result { - let ticket_path = encode_path_segment(ticket); - let show_response = self - .client - .execute(WorkspaceRequest::json( - WorkspaceRequestMethod::Post, - format!("/api/w/{workspace_id}/tickets/{ticket_path}/show"), - json!({"event_limit": 1}).to_string(), - )) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - if !show_response.is_success() { - return Err(api_error("Ticket Show API", &show_response)); - } - let projection: TicketMergeRequestProjection = serde_json::from_str(&show_response.body) - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "Ticket Show API returned a malformed Merge Request projection: {error}" - )) - })?; - let merge_request = projection.merge_request.ok_or_else(|| { - ToolError::ExecutionFailed(format!("Ticket `{ticket}` has no current Merge Request")) - })?; - nonempty_id("merge_request_id", &merge_request.merge_request_id)?; - - let merge_request_id = encode_path_segment(&merge_request.merge_request_id); + let merge_request_id = encode_path_segment(merge_request_id); let response = self .client .execute(WorkspaceRequest::get(format!( @@ -313,10 +315,10 @@ fn encode_path_segment(value: &str) -> String { encoded } -fn nonempty_id(name: &str, value: &str) -> Result<(), ToolError> { +fn nonempty_named(name: &str, value: &str) -> Result<(), ToolError> { if value.trim().is_empty() || value.chars().any(char::is_control) { - Err(ToolError::ExecutionFailed(format!( - "Ticket Show API returned an invalid {name}" + Err(ToolError::InvalidArgument(format!( + "{name} must not be empty" ))) } else { Ok(()) @@ -404,10 +406,13 @@ pub fn description(n: &str) -> Option<&'static str> { "Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.", ), "OpenMergeRequest" => Some( - "Open the Ticket's one Merge Request with immutable source and target selectors; reuse it and advance only selector_from with a normal non-force push for later fixes.", + "Open one repository-scoped Merge Request linked to the Ticket; reuse an existing Merge Request for that repository and advance only its selector_from with a normal non-force push.", ), "CompleteMergeRequest" => Some( - "Record Orchestrator-owned integration using unchanged-source approval and refreshed final target-ref evidence.", + "Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.", + ), + "CompleteTicket" => Some( + "Complete the Ticket only after reviewing its current item revision and exact linked Merge Request result set; this releases the current assignment atomically.", ), "ReviewMergeRequest" => Some( "Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.", @@ -478,6 +483,7 @@ mod tests { "CheckMergeRequestReadiness", "OpenMergeRequest", "CompleteMergeRequest", + "CompleteTicket", "ReviewMergeRequest", ] { assert!(description(name).is_some(), "missing operation {name}"); @@ -497,18 +503,20 @@ mod tests { } #[tokio::test] - async fn show_resolves_ticket_projection_then_reads_canonical_resource() { - let client = Arc::new(RecordingWorkspaceClient::new(vec![ - response(json!({"merge_request":{"merge_request_id":"MR/1"}})), - response(json!({"merge_request_id":"MR/1","state":"open"})), - ])); + async fn show_reads_explicit_merge_request_resource() { + let client = Arc::new(RecordingWorkspaceClient::new(vec![response( + json!({"merge_request_id":"MR/1","state":"open"}), + )])); let tool = MergeRequestTool { kind: Kind::Show, client: client.clone(), }; let output = tool - .execute(r#"{"ticket":"T/1"}"#, ToolExecutionContext::default()) + .execute( + r#"{"merge_request_id":"MR/1"}"#, + ToolExecutionContext::default(), + ) .await .expect("show should succeed"); @@ -517,18 +525,14 @@ mod tests { Some(r#"{"merge_request_id":"MR/1","state":"open"}"#) ); let requests = client.requests.lock().expect("request lock"); - assert_eq!(requests.len(), 2); - assert_eq!(requests[0].method, WorkspaceRequestMethod::Post); - assert_eq!(requests[0].path, "/api/w/ws/tickets/T%2F1/show"); - assert_eq!(requests[1].method, WorkspaceRequestMethod::Get); - assert_eq!(requests[1].path, "/api/w/ws/merge-requests/MR%2F1"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); + assert_eq!(requests[0].path, "/api/w/ws/merge-requests/MR%2F1"); } #[tokio::test] - async fn show_fails_closed_when_ticket_has_no_current_merge_request() { - let client = Arc::new(RecordingWorkspaceClient::new(vec![response( - json!({"merge_request":null}), - )])); + async fn show_requires_explicit_merge_request_identity() { + let client = Arc::new(RecordingWorkspaceClient::new(vec![])); let tool = MergeRequestTool { kind: Kind::Show, client: client.clone(), @@ -537,10 +541,10 @@ mod tests { let error = tool .execute(r#"{"ticket":"T1"}"#, ToolExecutionContext::default()) .await - .expect_err("missing Merge Request must fail"); + .expect_err("ticket-only lookup must fail"); - assert!(error.to_string().contains("no current Merge Request")); - assert_eq!(client.requests.lock().expect("request lock").len(), 1); + assert!(error.to_string().contains("merge_request_id")); + assert!(client.requests.lock().expect("request lock").is_empty()); } fn install(config: MergeRequestFeatureConfig) -> (Vec, Vec) { @@ -596,7 +600,8 @@ mod tests { [ "ShowMergeRequest", "CheckMergeRequestReadiness", - "CompleteMergeRequest" + "CompleteMergeRequest", + "CompleteTicket" ] ); assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]); diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index 199837f95..a63e6174b 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -27,6 +27,7 @@ struct ModelTicketQueryItem { unresolved_blocker_count: usize, unresolved_review_count: usize, evidence: Option, + merge_requests: Vec, merge_request: Option, } @@ -46,6 +47,7 @@ pub(super) struct ModelTicketDetail { assignments: Vec, current_coder: Option, implementation_reports: Vec, + merge_requests: Vec, merge_request: Option, evidence: Option, actions: Option, @@ -162,6 +164,8 @@ struct ModelEvidenceEvent { #[derive(Debug, Serialize)] struct ModelMergeRequest { + merge_request_id: String, + repository_key: String, state: String, selector_from: Option, selector_to: String, @@ -251,6 +255,10 @@ fn project_ticket_query_item(value: &Value) -> Result, _>>()?, merge_request: item .get("merge_request") .filter(|value| !value.is_null()) @@ -295,6 +303,10 @@ pub(super) fn project_ticket_detail(value: Value) -> Result, _>>()?, + merge_requests: array_field(root, "merge_requests")? + .iter() + .map(project_merge_request) + .collect::, _>>()?, merge_request: root .get("merge_request") .filter(|value| !value.is_null()) @@ -498,11 +510,13 @@ fn project_evidence_event(value: &Value) -> Result { fn project_merge_request(value: &Value) -> Result { let merge = object(value, "Merge Request summary")?; Ok(ModelMergeRequest { + merge_request_id: string_field(merge, "merge_request_id")?, + repository_key: string_field(merge, "repository_key")?, state: string_field(merge, "state")?, selector_from: optional_string(merge, "selector_from")?, selector_to: string_field(merge, "selector_to")?, review_status: string_field(merge, "review_status")?, - subject_ref: optional_string(merge, "subject_ref")?, + subject_ref: optional_string(merge, "current_subject_ref")?, review_excerpt: optional_string(merge, "review_excerpt")?, }) } @@ -721,6 +735,7 @@ mod tests { "complete_for_integration": false, "missing": ["merge_request"] }, + "merge_requests": [], "merge_request": null }] })).expect("Ticket query projection"); @@ -788,6 +803,7 @@ mod tests { "implementation_reports": [], "assignments": [], "current_coder": null, + "merge_requests": [], "merge_request": null, "evidence": { "has_merge_request": false, diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 1a73bc21e..97e63b940 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -721,6 +721,7 @@ mod tests { "ReviewMergeRequest", "CheckMergeRequestReadiness", "CompleteMergeRequest", + "CompleteTicket", ] { assert!(combined.contains(operation), "missing {operation}"); } diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 18dfa2a79..f7bd11149 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -78,6 +78,7 @@ struct SubWorkerSpawnInput { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct ReviewerHandoffInput { ticket_id: String, + merge_request_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -369,9 +370,9 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro let Some(review) = &input.review else { return Ok(()); }; - if review.ticket_id.trim().is_empty() { + if review.ticket_id.trim().is_empty() || review.merge_request_id.trim().is_empty() { return Err(ToolError::InvalidArgument( - "reviewer handoff requires non-empty ticket_id".to_string(), + "reviewer handoff requires non-empty ticket_id and merge_request_id".to_string(), )); } if input.profile.as_deref() != Some("builtin:reviewer") { @@ -484,6 +485,7 @@ impl Tool for SubWorkerSpawnTool { let reviewer_capability = input.review.as_ref().map(|review| { ( review.ticket_id.clone(), + review.merge_request_id.clone(), format!( "{}{}", uuid::Uuid::now_v7().simple(), @@ -492,7 +494,7 @@ impl Tool for SubWorkerSpawnTool { ) }); let child_workspace_context = - if let Some((ticket_id, capability_token)) = &reviewer_capability { + if let Some((ticket_id, merge_request_id, capability_token)) = &reviewer_capability { let workspace_id = self.workspace_context .workspace_id() @@ -513,6 +515,7 @@ impl Tool for SubWorkerSpawnTool { parent_client.clone(), ReviewerContext { ticket_id: ticket_id.clone(), + merge_request_id: merge_request_id.clone(), }, capability_token.clone(), )); @@ -610,7 +613,7 @@ impl Tool for SubWorkerSpawnTool { child_registry .attach_parent_protocol(session.protocol_sender(), session.session_id_string()); - if let Some((ticket_id, capability_token)) = &reviewer_capability { + if let Some((ticket_id, merge_request_id, capability_token)) = &reviewer_capability { let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| { ToolError::ExecutionFailed("review capability lost Workspace identity".to_string()) })?; @@ -640,15 +643,16 @@ impl Tool for SubWorkerSpawnTool { ))); } let body = serde_json::json!({ + "ticket_id": ticket_id, "child_session_id": child_session_id, "capability_token": capability_token, }); let request = WorkspaceRequest::json( WorkspaceRequestMethod::Post, format!( - "/api/w/{}/tickets/{}/merge-request/review-capabilities", + "/api/w/{}/merge-requests/{}/review-capabilities", workspace_id.as_str(), - ticket_id + merge_request_id ), body.to_string(), ); @@ -1170,21 +1174,21 @@ mod tests { "name":"reviewer","task":"review","profile":"builtin:reviewer", "scope":[{"target":"work","permission":"write"}], "command":true, - "review":{"ticket_id":"T1"} + "review":{"ticket_id":"T1","merge_request_id":"MR1"} })) .unwrap(); assert!(validate_reviewer_handoff(&valid).is_ok()); let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:coder", "scope":[{"target":"work","permission":"write"}], - "review":{"ticket_id":"T1"} + "review":{"ticket_id":"T1","merge_request_id":"MR1"} })) .unwrap(); assert!(validate_reviewer_handoff(&wrong_profile).is_err()); let read_only: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:reviewer", "scope":[{"target":"work","permission":"read"}], - "review":{"ticket_id":"T1"} + "review":{"ticket_id":"T1","merge_request_id":"MR1"} })) .unwrap(); assert!(validate_reviewer_handoff(&read_only).is_err()); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index eea5fda12..ef8bd428c 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -719,6 +719,7 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReviewerContext { pub ticket_id: String, + pub merge_request_id: String, } #[derive(Debug)] @@ -771,8 +772,9 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { let ticket_id = &self.context.ticket_id; let ticket_query_path = format!("/api/w/{workspace_id}/tickets/query"); let ticket_show_path = format!("/api/w/{workspace_id}/tickets/{ticket_id}/show"); + let merge_request_id = &self.context.merge_request_id; let review_path = - format!("/api/w/{workspace_id}/tickets/{ticket_id}/merge-request/reviews"); + format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}/reviews"); let read_allowed = request.method == WorkspaceRequestMethod::Get || (request.method == WorkspaceRequestMethod::Post && (request.path == ticket_query_path || request.path == ticket_show_path)); @@ -952,6 +954,7 @@ mod reviewer_client_tests { inner, ReviewerContext { ticket_id: "T1".into(), + merge_request_id: "MR1".into(), }, "secret".into(), ) @@ -1015,7 +1018,7 @@ mod reviewer_client_tests { client .execute(WorkspaceRequest::json( WorkspaceRequestMethod::Post, - "/api/w/ws/tickets/T1/merge-request/reviews", + "/api/w/ws/merge-requests/MR1/reviews", r#"{"decision":"approve"}"#.to_string(), )) .expect("attested review should be forwarded"); diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 23740b988..3ef844dbc 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -2,8 +2,7 @@ use std::{path::PathBuf, sync::Arc}; use chrono::{DateTime, Utc}; use merge_request::{ - MergeRequest, MergeRequestError, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, - ReviewDecision, + MergeRequest, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, ReviewDecision, }; use project_record::{allocate_record_id, unix_epoch_millis_now}; use rusqlite::{params_from_iter, types::Value as SqlValue}; @@ -856,8 +855,8 @@ impl SqliteWorkspaceAuthority { }) }) .collect::>>()?; - let write_repository_key = ticket_write_target_repository_key(&targets); - let has_target = write_repository_key.is_some(); + let write_repository_keys = ticket_write_target_repository_keys(&targets); + let has_target = !write_repository_keys.is_empty(); let has_blockers = !ticket.relations.blockers.is_empty(); let mut queue_assignment_blockers = Vec::new(); for ticket_id in &dependency_check.queue_tickets { @@ -929,8 +928,12 @@ impl SqliteWorkspaceAuthority { queue_tickets: dependency_check.queue_tickets.clone(), blockers: action_blockers, }; - let merge_request = match self.merge_request_store.get(&self.workspace_id, id) { - Ok(request) => { + let merge_requests = self + .merge_request_store + .list_for_ticket(&self.workspace_id, id) + .map_err(|error| Error::Store(error.to_string()))? + .into_iter() + .map(|request| { let current_subject_ref = request.selector_from.as_deref().and_then(|selector| { self.merge_revision_source .resolve_subject_ref(&request.repository_id, selector) @@ -940,17 +943,16 @@ impl SqliteWorkspaceAuthority { .get_repository(&self.workspace_id, &request.repository_id)? .map(|repository| repository.repository_key) .ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?; - Some(merge_request_summary( + Ok(merge_request_summary( request, repository_key, current_subject_ref, )) - } - Err(MergeRequestError::NotFound) => None, - Err(error) => return Err(Error::Store(error.to_string())), - }; + }) + .collect::>>()?; + let merge_request = (merge_requests.len() == 1).then(|| merge_requests[0].clone()); let evidence = - ticket_evidence_summary(write_repository_key, &ticket.events, merge_request.as_ref()); + ticket_evidence_summary(&write_repository_keys, &ticket.events, &merge_requests); let item_revision = ticket .events .iter() @@ -1031,6 +1033,7 @@ impl SqliteWorkspaceAuthority { current_coder, assignment_diagnostics, action_eligibility, + merge_requests, merge_request, evidence, resolution: ticket @@ -1850,81 +1853,93 @@ fn event_timestamp(event: &TicketEvent) -> Option> { .map(|value| value.with_timezone(&Utc)) } -fn ticket_write_target_repository_key(targets: &[ticket::TicketTarget]) -> Option<&str> { - let mut write_targets = targets +fn ticket_write_target_repository_keys(targets: &[ticket::TicketTarget]) -> Vec<&str> { + targets .iter() - .filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite); - let write_target = write_targets.next()?; - write_targets - .next() - .is_none() - .then_some(write_target.repository_key.as_str()) + .filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite) + .map(|target| target.repository_key.as_str()) + .collect() } fn ticket_evidence_summary( - ticket_write_repository_key: Option<&str>, + ticket_write_repository_keys: &[&str], events: &[TicketEvent], - merge_request: Option<&TicketMergeRequestSummary>, + merge_requests: &[TicketMergeRequestSummary], ) -> TicketEvidenceSummary { - let linked_merge_request = merge_request.filter(|request| { - request.state == "open" - && ticket_write_repository_key - .is_some_and(|repository_key| repository_key == request.repository_key) - }); - let has_merge_request = linked_merge_request.is_some(); - let has_current_subject_ref = linked_merge_request.is_some_and(|request| { - request - .current_subject_ref - .as_deref() - .is_some_and(|subject_ref| !subject_ref.is_empty()) - }); - let has_review_request = linked_merge_request.is_some_and(|request| { - request - .review_subject_ref - .as_deref() - .is_some_and(|subject_ref| !subject_ref.is_empty()) - }); + let relevant = merge_requests + .iter() + .filter(|request| { + request.state != "closed" + && ticket_write_repository_keys.contains(&request.repository_key.as_str()) + }) + .collect::>(); + let has_merge_request = !relevant.is_empty(); + let has_current_subject_ref = has_merge_request + && relevant.iter().all(|request| { + request.state == "merged" + || request + .current_subject_ref + .as_deref() + .is_some_and(|subject_ref| !subject_ref.is_empty()) + }); + let has_review_request = has_merge_request + && relevant.iter().all(|request| { + request.state == "merged" + || request + .review_subject_ref + .as_deref() + .is_some_and(|subject_ref| !subject_ref.is_empty()) + }); let has_commit = has_current_subject_ref; - let review_status = linked_merge_request.map(|request| request.review_status.clone()); - let approved_current_subject = review_status.as_deref() == Some("approved"); - let unresolved_request_changes = review_status.as_deref() == Some("changes_requested"); + let unresolved_request_changes = relevant + .iter() + .any(|request| request.review_status == "changes_requested"); + let approved_current_subject = has_merge_request + && relevant + .iter() + .all(|request| request.state == "merged" || request.review_status == "approved"); + let review_status = if !has_merge_request { + None + } else if unresolved_request_changes { + Some("changes_requested".to_string()) + } else if approved_current_subject { + Some("approved".to_string()) + } else { + Some("pending".to_string()) + }; let latest_rescope = events .iter() .filter(|event| substantive_item_edit(event)) .last(); let review_after_rescope = approved_current_subject - && latest_rescope.is_none_or(|rescope| { - let Some(rescope_at) = event_timestamp(rescope) else { - return false; - }; - let Some(requested_at) = linked_merge_request - .and_then(|request| request.review_requested_at.as_deref()) - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - else { - return false; - }; - let Some(reviewed_at) = linked_merge_request - .and_then(|request| request.review_submitted_at.as_deref()) - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - else { - return false; - }; - requested_at > rescope_at && reviewed_at > rescope_at + && relevant.iter().all(|request| { + latest_rescope.is_none_or(|rescope| { + let Some(rescope_at) = event_timestamp(rescope) else { + return false; + }; + let Some(requested_at) = request + .review_requested_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)) + else { + return false; + }; + let Some(reviewed_at) = request + .review_submitted_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)) + else { + return false; + }; + requested_at > rescope_at && reviewed_at > rescope_at + }) }); let mut missing = Vec::new(); - match merge_request { - None => missing.push("merge_request".to_string()), - Some(request) if request.state != "open" => missing.push("open_merge_request".to_string()), - Some(request) - if ticket_write_repository_key - .is_none_or(|repository_key| repository_key != request.repository_key) => - { - missing.push("merge_request_repository".to_string()) - } - Some(_) => {} + if !has_merge_request { + missing.push("merge_request".to_string()); } if !has_current_subject_ref { missing.push("current_subject_ref".to_string()); @@ -2349,6 +2364,7 @@ fn ticket_query_item( unresolved_blocker_count: detail.relations.blockers.len(), unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes), evidence: detail.evidence.clone(), + merge_requests: detail.merge_requests.clone(), merge_request: detail.merge_request.clone(), current_coder: detail.current_coder.clone(), } @@ -2789,6 +2805,11 @@ mod tests { event_id: "request-1".to_string(), sequence: 1, subject_ref: "commit-1".to_string(), + ticket_item_revision: "revision-1".to_string(), + ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject { + merge_request_id: "mr-1".to_string(), + subject_ref: "commit-1".to_string(), + }], requested_by: reviewer.clone(), reviewer: reviewer.clone(), created_at: requested_at, @@ -2798,6 +2819,11 @@ mod tests { sequence: 2, request_event_id: "request-1".to_string(), subject_ref: "commit-1".to_string(), + ticket_item_revision: "revision-1".to_string(), + ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject { + merge_request_id: "mr-1".to_string(), + subject_ref: "commit-1".to_string(), + }], decision, body: "review body".to_string(), findings: Vec::new(), @@ -2879,33 +2905,39 @@ mod tests { } #[test] - fn ticket_target_projection_requires_exactly_one_write_target() { + fn ticket_target_projection_accepts_multiple_write_targets() { let target = |repository_key: &str, access| ticket::TicketTarget { repository_key: repository_key.to_string(), ref_selector: Some("develop".to_string()), access, }; - assert_eq!(ticket_write_target_repository_key(&[]), None); - assert_eq!( - ticket_write_target_repository_key(&[target( + assert!(ticket_write_target_repository_keys(&[]).is_empty()); + assert!( + ticket_write_target_repository_keys(&[target( "docs", ticket::TicketTargetAccess::ReadOnly, - )]), - None, + )]) + .is_empty() ); let one_write = [ target("main", ticket::TicketTargetAccess::ReadWrite), target("docs", ticket::TicketTargetAccess::ReadOnly), ]; - assert_eq!(ticket_write_target_repository_key(&one_write), Some("main"),); + assert_eq!( + ticket_write_target_repository_keys(&one_write), + vec!["main"] + ); let two_writes = [ target("main", ticket::TicketTargetAccess::ReadWrite), target("docs", ticket::TicketTargetAccess::ReadWrite), ]; - assert_eq!(ticket_write_target_repository_key(&two_writes), None); + assert_eq!( + ticket_write_target_repository_keys(&two_writes), + vec!["main", "docs"] + ); } #[test] @@ -2915,15 +2947,12 @@ mod tests { "docs".to_string(), Some("commit-1".to_string()), ); - let evidence = ticket_evidence_summary(Some("main"), &[], Some(&read_only_request)); + let evidence = + ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&read_only_request)); assert!(!evidence.has_merge_request); assert!(!evidence.complete_for_integration); - assert!( - evidence - .missing - .contains(&"merge_request_repository".to_string()) - ); + assert!(evidence.missing.contains(&"merge_request".to_string())); } #[test] @@ -2933,19 +2962,19 @@ mod tests { "main".to_string(), Some("commit-1".to_string()), ); - let evidence = ticket_evidence_summary(Some("main"), &[], Some(&approved)); + let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&approved)); assert!(evidence.complete_for_integration); assert!(evidence.approved_current_subject); assert!(evidence.review_after_rescope); assert!(evidence.has_commit); let with_audit_events = ticket_evidence_summary( - Some("main"), + &["main"], &[ ticket_event("comment", "2026-01-01T00:05:00Z", None), ticket_event("implementation_report", "2026-01-01T00:06:00Z", None), ], - Some(&approved), + std::slice::from_ref(&approved), ); assert!(with_audit_events.complete_for_integration); @@ -2954,7 +2983,7 @@ mod tests { "main".to_string(), Some("commit-1".to_string()), ); - let evidence = ticket_evidence_summary(Some("main"), &[], Some(&revoked)); + let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&revoked)); assert!(!evidence.approved_current_subject); assert!(!evidence.complete_for_integration); @@ -2963,7 +2992,7 @@ mod tests { "main".to_string(), Some("commit-1".to_string()), ); - let evidence = ticket_evidence_summary(Some("main"), &[], Some(&changes)); + let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&changes)); assert!(evidence.unresolved_request_changes); assert!(!evidence.complete_for_integration); } @@ -2975,7 +3004,7 @@ mod tests { "main".to_string(), None, ); - let evidence = ticket_evidence_summary(Some("main"), &[], Some(&unresolved)); + let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&unresolved)); assert!(!evidence.has_current_subject_ref); assert!(!evidence.has_commit); assert!(!evidence.complete_for_integration); @@ -2987,10 +3016,10 @@ mod tests { "main".to_string(), Some("commit-1".to_string()), ); - let evidence = ticket_evidence_summary(Some("main"), &[], Some(&closed)); + let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&closed)); assert!(!evidence.has_merge_request); assert!(!evidence.complete_for_integration); - assert!(evidence.missing.contains(&"open_merge_request".to_string())); + assert!(evidence.missing.contains(&"merge_request".to_string())); } #[test] @@ -3001,38 +3030,38 @@ mod tests { Some("commit-1".to_string()), ); let fresh = ticket_evidence_summary( - Some("main"), + &["main"], &[ticket_event( "item_edit", "2026-01-01T00:02:00Z", Some("body"), )], - Some(&approved), + std::slice::from_ref(&approved), ); assert!(fresh.review_after_rescope); assert!(fresh.complete_for_integration); let stale = ticket_evidence_summary( - Some("main"), + &["main"], &[ticket_event( "item_edit", "2026-01-01T00:05:00Z", Some("title"), )], - Some(&approved), + std::slice::from_ref(&approved), ); assert!(!stale.review_after_rescope); assert!(!stale.complete_for_integration); assert!(stale.missing.contains(&"review_after_rescope".to_string())); let metadata_only = ticket_evidence_summary( - Some("main"), + &["main"], &[ticket_event( "item_edit", "2026-01-01T00:05:00Z", Some("formatter"), )], - Some(&approved), + std::slice::from_ref(&approved), ); assert!(metadata_only.complete_for_integration); } @@ -3044,7 +3073,8 @@ mod tests { "main".to_string(), Some("commit-1".to_string()), ); - let approved = ticket_evidence_summary(Some("main"), &[], Some(&approved_summary)); + let approved = + ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&approved_summary)); assert!(ticket_evidence_matches(&approved, "merge_request")); assert!(ticket_evidence_matches(&approved, "commit")); assert!(ticket_evidence_matches(&approved, "approved_review")); @@ -3061,7 +3091,8 @@ mod tests { "main".to_string(), Some("commit-2".to_string()), ); - let pending = ticket_evidence_summary(Some("main"), &[], Some(&pending_summary)); + let pending = + ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&pending_summary)); assert!(ticket_attention_matches( "inprogress", &pending, @@ -3076,7 +3107,11 @@ mod tests { "2026-01-01T00:05:00Z", Some("targets"), )]; - let stale = ticket_evidence_summary(Some("main"), &stale_events, Some(&approved_summary)); + let stale = ticket_evidence_summary( + &["main"], + &stale_events, + std::slice::from_ref(&approved_summary), + ); assert!(ticket_attention_matches( "inprogress", &stale, diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 2f263ff81..a9a769d0e 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -2222,8 +2222,7 @@ impl EmbeddedWorkerRuntime { identity: "runtime_registry_worker".to_string(), workspace_id: summary.workspace_id.clone(), }, - availability: - protocol::subscription::SubscriptionWorkerAvailability::Observed, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, state: embedded_worker_state_label( summary.status, summary.execution_metadata_available, @@ -2271,8 +2270,7 @@ impl EmbeddedWorkerRuntime { identity: "runtime_registry_worker".to_string(), workspace_id: detail.workspace_id.clone(), }, - availability: - protocol::subscription::SubscriptionWorkerAvailability::Observed, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, state: embedded_worker_state_label(detail.status, detail.execution_metadata_available) .to_string(), worker_state: detail.worker_state.clone(), @@ -4064,8 +4062,7 @@ impl RemoteWorkerRuntime { identity: "runtime_registry_worker".to_string(), workspace_id: summary.workspace_id.clone(), }, - availability: - protocol::subscription::SubscriptionWorkerAvailability::Observed, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, state: embedded_worker_state_label( summary.status, summary.execution_metadata_available, @@ -4111,8 +4108,7 @@ impl RemoteWorkerRuntime { identity: "runtime_registry_worker".to_string(), workspace_id: detail.workspace_id.clone(), }, - availability: - protocol::subscription::SubscriptionWorkerAvailability::Observed, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, state: embedded_worker_state_label(detail.status, detail.execution_metadata_available) .to_string(), worker_state: detail.worker_state.clone(), @@ -5979,18 +5975,14 @@ mod tests { #[test] fn embedded_store_commit_unknown_diagnostic_is_path_free() { - let diagnostic = embedded_runtime_diagnostic( - &EmbeddedRuntimeError::StoreCommitOutcomeUnknown { + let diagnostic = + embedded_runtime_diagnostic(&EmbeddedRuntimeError::StoreCommitOutcomeUnknown { operation: "sync Worker aggregate directory", path: std::path::PathBuf::from("/private/runtime/workers/worker.json"), source: std::io::Error::other("raw fsync failure"), - }, - ); + }); - assert_eq!( - diagnostic.code, - "embedded_runtime_store_commit_unknown" - ); + assert_eq!(diagnostic.code, "embedded_runtime_store_commit_unknown"); assert_eq!(diagnostic.severity, HostDiagnosticSeverity::Error); assert!(!diagnostic.message.contains("/private/runtime")); assert!(!diagnostic.message.contains("raw fsync failure")); @@ -6547,8 +6539,7 @@ mod tests { identity: host_id.to_string(), workspace_id: None, }, - availability: - protocol::subscription::SubscriptionWorkerAvailability::Observed, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, state: "available".to_string(), worker_state: None, last_seen_at: None, diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index b6b8b44c4..674bee27d 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -697,10 +697,10 @@ CREATE TABLE external_workdir_grants ( workdir_id TEXT NOT NULL, provider_instance_id TEXT NOT NULL, display_name TEXT NOT NULL, - permissions TEXT NOT NULL CHECK (permissions = 'read_only'), + permissions TEXT NOT NULL CHECK (permissions IN ('read_only', 'read_write')), created_by TEXT NOT NULL, created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, + expires_at TEXT, generation INTEGER NOT NULL CHECK (generation > 0), status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), updated_at TEXT NOT NULL, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index c689ef820..69cfef1d0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -729,7 +729,7 @@ struct ExternalProviderConnection { workdir_id: String, provider_instance_id: String, generation: u64, - expires_at: chrono::DateTime, + expires_at: Option>, capabilities: workdir::WorkdirSessionCapabilities, admission: Arc, sender: tokio::sync::mpsc::Sender, @@ -861,7 +861,11 @@ impl ExternalProviderWorkdirSession { if self.closed.load(Ordering::Acquire) { return Err(workdir::WorkdirError::SessionClosed); } - if Utc::now() >= self.connection.expires_at { + if self + .connection + .expires_at + .is_some_and(|expires_at| Utc::now() >= expires_at) + { return Err(workdir::WorkdirError::Unavailable( "External Workdir grant has expired".to_string(), )); @@ -877,7 +881,11 @@ impl ExternalProviderWorkdirSession { "External Workdir provider admission is closed".to_string(), ) })?; - if Utc::now() >= self.connection.expires_at { + if self + .connection + .expires_at + .is_some_and(|expires_at| Utc::now() >= expires_at) + { return Err(workdir::WorkdirError::Unavailable( "External Workdir grant has expired".to_string(), )); @@ -1020,19 +1028,42 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { } async fn write( &self, - _request: workdir::WriteRequest, + request: workdir::WriteRequest, ) -> std::result::Result { - Err(workdir::WorkdirError::Unsupported( - workdir::WorkdirSessionCapability::Write, - )) + if !self + .connection + .capabilities + .supports(workdir::WorkdirSessionCapability::Write) + { + return Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Write, + )); + } + match self + .operate(WorkdirSessionOperation::Write(request)) + .await? + { + WorkdirSessionOperationResult::Write(value) => Ok(value), + _ => Err(Self::mismatch("write")), + } } async fn edit( &self, - _request: workdir::EditRequest, + request: workdir::EditRequest, ) -> std::result::Result { - Err(workdir::WorkdirError::Unsupported( - workdir::WorkdirSessionCapability::Edit, - )) + if !self + .connection + .capabilities + .supports(workdir::WorkdirSessionCapability::Edit) + { + return Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Edit, + )); + } + match self.operate(WorkdirSessionOperation::Edit(request)).await? { + WorkdirSessionOperationResult::Edit(value) => Ok(value), + _ => Err(Self::mismatch("edit")), + } } async fn list( &self, @@ -3415,9 +3446,7 @@ impl WorkspaceApi { code: "external_workdir_unavailable".to_string(), message: "External Workdir grant is unavailable".to_string(), })?; - let grant_expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - .map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now()) - .unwrap_or(true); + let grant_expired = external_workdir_grant_is_expired(&grant)?; let connection = self .external_workdir_providers .lock() @@ -3429,7 +3458,9 @@ impl WorkspaceApi { connection.generation == grant.generation && connection.provider_instance_id == grant.provider_instance_id && connection.workdir_id == link.workdir_id - && connection.expires_at > Utc::now() + && connection + .expires_at + .is_none_or(|expires_at| expires_at > Utc::now()) }) { return Err(Error::RuntimeOperationFailed { @@ -3519,7 +3550,7 @@ impl WorkspaceApi { claim.working_directory_id ))) })?; - claim.capabilities = workdir_source_capabilities(&workdir); + claim.capabilities = workdir_source_capabilities(self, &workdir)?; } return Ok(()); }; @@ -3607,7 +3638,7 @@ impl WorkspaceApi { } claim.capabilities = target .capabilities - .intersection(workdir_source_capabilities(&workdir)); + .intersection(workdir_source_capabilities(self, &workdir)?); } let missing = targets @@ -3643,14 +3674,27 @@ struct ValidatedTicketImplementationTarget { capabilities: workdir::WorkdirSessionCapabilities, } +fn external_workdir_capabilities(permissions: &str) -> Result { + match permissions { + "read_only" => Ok(workdir::WorkdirSessionCapabilities::READ_ONLY), + "read_write" => Ok(workdir::WorkdirSessionCapabilities::READ_WRITE), + _ => Err(Error::Store( + "External Workdir grant has invalid persisted permissions".to_string(), + )), + } +} + fn workdir_source_capabilities( + api: &WorkspaceApi, workdir: &WorkdirRegistryRecord, -) -> workdir::WorkdirSessionCapabilities { +) -> Result { match &workdir.source { - WorkdirRegistrySource::Repository { .. } => workdir::WorkdirSessionCapabilities::ALL, - WorkdirRegistrySource::ExternalGrant { .. } => { - workdir::WorkdirSessionCapabilities::READ_ONLY - } + WorkdirRegistrySource::Repository { .. } => Ok(workdir::WorkdirSessionCapabilities::ALL), + WorkdirRegistrySource::ExternalGrant { grant_id } => api + .store + .get_external_workdir_grant(&api.config.workspace_id, grant_id)? + .ok_or_else(|| Error::Store("External Workdir grant is unavailable".to_string())) + .and_then(|grant| external_workdir_capabilities(&grant.permissions)), } } @@ -3673,9 +3717,9 @@ fn validated_ticket_implementation_targets( .iter() .filter(|target| target.access == TicketTargetAccess::ReadWrite) .count(); - if read_write_count != 1 { + if read_write_count == 0 { return Err(Error::InvalidInput(format!( - "Ticket `{ticket_id}` requires exactly one read_write target, found {read_write_count}" + "Ticket `{ticket_id}` requires at least one read_write target" )) .into()); } @@ -3732,14 +3776,16 @@ fn require_ticket_read_write_target( ) -> ApiResult { let write_target = validated_ticket_implementation_targets(api, ticket_id)? .into_iter() - .find(|target| target.access == TicketTargetAccess::ReadWrite) - .expect("validated Ticket target collection has exactly one read_write target"); - if write_target.repository_id != repository_id || write_target.ref_selector != selector { - return Err(Error::InvalidInput( - "selectors must match the authoritative Ticket read_write repository target".into(), - ) - .into()); - } + .find(|target| { + target.access == TicketTargetAccess::ReadWrite + && target.repository_id == repository_id + && target.ref_selector == selector + }) + .ok_or_else(|| { + ApiError::from(Error::InvalidInput( + "repository and selector must match a declared Ticket read_write target".into(), + )) + })?; Ok(write_target) } @@ -3748,7 +3794,7 @@ fn ticket_target_capabilities_for_workdir( worker: &RuntimeWorkerRef, workdir: &WorkdirRegistryRecord, ) -> Result { - let source_capabilities = workdir_source_capabilities(workdir); + let source_capabilities = workdir_source_capabilities(api, workdir)?; let Some(assignment) = api .store .get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)? @@ -3885,7 +3931,7 @@ fn validate_manual_ticket_coder_workdir_binding( } let capabilities = link .capabilities - .intersection(workdir_source_capabilities(&workdir)) + .intersection(workdir_source_capabilities(api, &workdir)?) .intersection(target.capabilities); effective_links.push(WorkerWorkdirLinkRecord { capabilities, @@ -4398,6 +4444,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::merge_request_complete( service.clone(), )) + .merge(server_api::server_api_axum::ticket_complete( + service.clone(), + )) .merge(server_api::server_api_axum::ticket_close_record( service.clone(), )) @@ -7769,6 +7818,25 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } + async fn ticket_complete( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::CompleteTicketRequest, + ) -> std::result::Result + { + let Json(response) = scoped_complete_ticket( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + AxumPath((workspace_id, id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + async fn ticket_close_record( &self, context: server_api::ServerRequestContext, @@ -12053,6 +12121,43 @@ fn resolve_workspace_worker_ticket_assignment( Ok(()) } +fn merge_request_ticket_id(mr: &merge_request::MergeRequest) -> ApiResult { + match mr.ticket_ids.as_slice() { + [ticket_id] => Ok(ticket_id.clone()), + [] => Err( + Error::MergeRequest(merge_request::MergeRequestError::Corrupt( + "Merge Request has no linked Ticket".into(), + )) + .into(), + ), + _ => Err( + Error::MergeRequest(merge_request::MergeRequestError::Conflict( + "Merge Request must be linked to exactly one workflow Ticket".into(), + )) + .into(), + ), + } +} + +fn merged_result_source_ref(mr: &merge_request::MergeRequest) -> ApiResult { + mr.thread + .iter() + .rev() + .find_map(|event| match event { + merge_request::MergeRequestThreadEvent::Merge(event) => { + Some(event.approved_source_ref.clone()) + } + _ => None, + }) + .ok_or_else(|| { + Error::MergeRequest(merge_request::MergeRequestError::Corrupt(format!( + "merged Merge Request `{}` has no MergeResult", + mr.merge_request_id + ))) + .into() + }) +} + fn public_merge_request_state( state: merge_request::MergeRequestState, ) -> server_api::MergeRequestState { @@ -12124,6 +12229,15 @@ fn public_review_event(event: merge_request::ReviewEvent) -> server_api::ReviewE sequence: event.sequence, request_event_id: event.request_event_id, subject_ref: event.subject_ref, + ticket_item_revision: event.ticket_item_revision, + ticket_merge_request_subjects: event + .ticket_merge_request_subjects + .into_iter() + .map(|subject| server_api::MergeRequestReviewSubject { + merge_request_id: subject.merge_request_id, + subject_ref: subject.subject_ref, + }) + .collect(), decision: public_review_decision(event.decision), body: event.body, findings: event @@ -12166,6 +12280,20 @@ fn public_merge_event(event: merge_request::MergeEvent) -> server_api::MergeEven } } +fn public_ticket_completion_event( + event: merge_request::TicketCompletionEvent, +) -> server_api::TicketCompletionEvent { + server_api::TicketCompletionEvent { + operation_id: event.operation_id, + ticket_id: event.ticket_id, + item_revision: event.item_revision, + merge_request_ids: event.merge_request_ids, + requirement_approval_event_id: event.requirement_approval_event_id, + completed_by: public_merge_request_worker_identity(event.completed_by), + created_at: event.created_at.to_rfc3339(), + } +} + fn public_merge_strategy(strategy: merge_request::MergeStrategy) -> server_api::MergeStrategy { match strategy { merge_request::MergeStrategy::FastForward => server_api::MergeStrategy::FastForward, @@ -12213,6 +12341,15 @@ fn public_merge_request_thread_event( event_id: event.event_id, sequence: event.sequence, subject_ref: event.subject_ref, + ticket_item_revision: event.ticket_item_revision, + ticket_merge_request_subjects: event + .ticket_merge_request_subjects + .into_iter() + .map(|subject| server_api::MergeRequestReviewSubject { + merge_request_id: subject.merge_request_id, + subject_ref: subject.subject_ref, + }) + .collect(), requested_by: public_merge_request_worker_identity(event.requested_by), reviewer: public_merge_request_worker_identity(event.reviewer), created_at: event.created_at.to_rfc3339(), @@ -12527,12 +12664,12 @@ async fn scoped_show_merge_request( async fn scoped_merge_request_readiness( State(api): State, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get(&workspace_id, &ticket_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; let assignment = api .store .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?; @@ -12555,6 +12692,7 @@ async fn scoped_merge_request_readiness( (None, _) => (None, None), }; let mut report = store.readiness(merge_request::ReadinessCheck { + merge_request_id, ticket_id, current_subject_ref, auth: merge_request::MergeRequestAuth { @@ -12657,14 +12795,13 @@ async fn scoped_open_merge_request( async fn scoped_merge_request_thread( State(api): State, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Query(query): Query, ) -> ApiResult>> { let workspace_id = parse_workspace_id(&workspace_id)?; - let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; - let events = merge_request_store(&api, &workspace_id)?.thread_page( + let events = merge_request_store(&api, &workspace_id)?.thread_page_by_id( &workspace_id, - &ticket_id, + &merge_request_id, query.after, query.limit.unwrap_or(100), )?; @@ -12679,11 +12816,10 @@ async fn scoped_merge_request_thread( async fn scoped_repair_merge_request_selector( State(api): State, headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; reject_non_browser_reopen_auth(&headers)?; let _actor = require_actor(&ServerAuthApi::from(&api), &headers).await?; @@ -12691,7 +12827,8 @@ async fn scoped_repair_merge_request_selector( return Err(Error::BrowserReopenConfirmationRequired.into()); } let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get(&workspace_id, &ticket_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; let assignment = api .store .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? @@ -12708,6 +12845,7 @@ async fn scoped_repair_merge_request_selector( .revision_ref; let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom { workspace_id: workspace_id.clone(), + merge_request_id, ticket_id, selector_from: input.selector_from, resolved_subject_ref, @@ -12746,13 +12884,23 @@ async fn scoped_register_reviewer_child_session( async fn scoped_register_merge_request_review_capability( State(api): State, headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult { let workspace_id = parse_workspace_id(&workspace_id)?; - let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; + let requested_ticket_id = + resolve_workspace_ticket_reference(&api, &workspace_id, &input.ticket_id)?; + if requested_ticket_id != ticket_id { + return Err(Error::TicketAssignmentConflict( + "Reviewer handoff Ticket does not match the addressed Merge Request".into(), + ) + .into()); + } let assignment = api .store .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? @@ -12767,29 +12915,66 @@ async fn scoped_register_merge_request_review_capability( ) .into()); } - let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get(&workspace_id, &ticket_id)?; - let selector = mr - .selector_from - .as_deref() - .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; - let source_observation = observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - selector, - )?; - require_assigned_workdir_source( - &api, - &assignment, - &mr.repository_id, - selector, - &source_observation.revision_ref, - )?; - let subject_ref = source_observation.revision_ref; + let subject_ref = match mr.state { + merge_request::MergeRequestState::Merged => merged_result_source_ref(&mr)?, + merge_request::MergeRequestState::Open => { + let selector = mr + .selector_from + .as_deref() + .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + let source_observation = observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + selector, + )?; + require_assigned_workdir_source( + &api, + &assignment, + &mr.repository_id, + selector, + &source_observation.revision_ref, + )?; + source_observation.revision_ref + } + merge_request::MergeRequestState::Closed => { + return Err( + Error::InvalidInput("closed Merge Request is not reviewable".into()).into(), + ); + } + }; + let mut ticket_merge_request_subjects = Vec::new(); + for linked in store.list_for_ticket(&workspace_id, &ticket_id)? { + let linked_subject_ref = if linked.state == merge_request::MergeRequestState::Merged { + merged_result_source_ref(&linked)? + } else { + let linked_selector = linked.selector_from.as_deref().ok_or_else(|| { + Error::InvalidInput(format!( + "Merge Request `{}` selector_from requires repair", + linked.merge_request_id + )) + })?; + observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &linked.repository_id, + linked_selector, + )? + .revision_ref + }; + ticket_merge_request_subjects.push(merge_request::MergeRequestReviewSubject { + merge_request_id: linked.merge_request_id, + subject_ref: linked_subject_ref, + }); + } + let ticket_item_revision = api.authority.ticket(&ticket_id)?.item_revision; store.request_review(merge_request::RequestMergeRequestReview { + merge_request_id, ticket_id, + ticket_item_revision, + ticket_merge_request_subjects, subject_ref, child_session_id: input.child_session_id, capability_token: input.capability_token, @@ -12807,15 +12992,16 @@ async fn scoped_register_merge_request_review_capability( async fn scoped_submit_merge_request_review( State(api): State, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; let store = merge_request_store(&api, &workspace_id)?; let review_authorization = - store.authorize_review_submission(&ticket_id, &input.capability_token)?; - if review_authorization.workspace_id != workspace_id { + store.authorize_review_submission(&merge_request_id, &input.capability_token)?; + if review_authorization.workspace_id != workspace_id + || review_authorization.merge_request_id != merge_request_id + { return Err( Error::MergeRequest(merge_request::MergeRequestError::Unauthorized( "review grant invalid".into(), @@ -12823,25 +13009,36 @@ async fn scoped_submit_merge_request_review( .into(), ); } - let mr = store.get(&workspace_id, &ticket_id)?; - let selector = mr - .selector_from - .as_deref() - .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + let ticket_id = review_authorization.ticket_id; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; let assignment = api .store .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) })?; - let current_subject_ref = observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - selector, - )? - .revision_ref; + let current_subject_ref = match mr.state { + merge_request::MergeRequestState::Merged => merged_result_source_ref(&mr)?, + merge_request::MergeRequestState::Open => { + let selector = mr + .selector_from + .as_deref() + .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + selector, + )? + .revision_ref + } + merge_request::MergeRequestState::Closed => { + return Err( + Error::InvalidInput("closed Merge Request is not reviewable".into()).into(), + ); + } + }; let decision = internal_review_decision(input.decision); let findings = input .findings @@ -12849,6 +13046,7 @@ async fn scoped_submit_merge_request_review( .map(internal_review_finding) .collect(); let event = store.submit_review(merge_request::SubmitMergeRequestReview { + merge_request_id, ticket_id, current_subject_ref, capability_token: input.capability_token, @@ -12863,16 +13061,18 @@ async fn scoped_submit_merge_request_review( async fn scoped_revoke_merge_request_review( State(api): State, headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; if !input.explicit_confirmation { return Err(Error::BrowserReopenConfirmationRequired.into()); } let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; let assignment = api .store .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? @@ -12887,9 +13087,8 @@ async fn scoped_revoke_merge_request_review( ) .into()); } - let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get(&workspace_id, &ticket_id)?; let event = store.revoke_review(merge_request::RevokeMergeRequestReview { + merge_request_id, ticket_id, review_event_id: input.review_event_id, reason: input.reason, @@ -12908,18 +13107,19 @@ async fn scoped_revoke_merge_request_review( async fn scoped_complete_merge_request( State(api): State, headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; require_online_workspace_orchestrator_source(&api, &source)?; let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get(&workspace_id, &ticket_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) { let replay = merge_request::CompleteMergeRequest { + merge_request_id, ticket_id, operation_id: input.operation_id, approval_event_id: input.approval_event_id, @@ -12974,6 +13174,7 @@ async fn scoped_complete_merge_request( &input.target_ref_after, )?; let completion = merge_request::CompleteMergeRequest { + merge_request_id, ticket_id, operation_id: input.operation_id, approval_event_id: input.approval_event_id, @@ -12999,6 +13200,42 @@ async fn scoped_complete_merge_request( .map_err(Into::into) } +async fn scoped_complete_ticket( + State(api): State, + headers: HeaderMap, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Json(input): Json, +) -> ApiResult> { + let workspace_id = parse_workspace_id(&workspace_id)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; + require_workspace_access(&workspace_id, &api)?; + let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + require_online_workspace_orchestrator_source(&api, &source)?; + let assignment_id = api + .store + .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? + .map(|assignment| assignment.assignment_id) + .unwrap_or_default(); + let event = merge_request_store(&api, &workspace_id)?.complete_ticket( + merge_request::CompleteTicket { + ticket_id, + operation_id: input.operation_id, + item_revision: input.item_revision, + merge_request_ids: input.merge_request_ids, + requirement_approval_event_id: input.requirement_approval_event_id, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: String::new(), + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id, + }, + now: Utc::now(), + }, + )?; + Ok(Json(public_ticket_completion_event(event))) +} + fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { if headers.contains_key("authorization") { return Err(Error::BrowserReopenConfirmationRequired); @@ -13622,7 +13859,7 @@ async fn open_current_worker_workdir_session_locked( })?; let effective_capabilities = link .capabilities - .intersection(workdir_source_capabilities(&workdir)) + .intersection(workdir_source_capabilities(api, &workdir)?) .intersection(ticket_target_capabilities_for_workdir( api, worker, &workdir, )?); @@ -13636,9 +13873,7 @@ async fn open_current_worker_workdir_session_locked( code: "external_workdir_grant_not_found".to_string(), message: "External Workdir grant is unavailable".to_string(), })?; - let expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - .map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now()) - .unwrap_or(true); + let expired = external_workdir_grant_is_expired(&grant)?; if expired { grant = fence_external_workdir_grant_expired(api, grant_id) .await @@ -13843,7 +14078,7 @@ fn sync_runtime_worker_workdir_attachments( })?; let capabilities = link .capabilities - .intersection(workdir_source_capabilities(&workdir)) + .intersection(workdir_source_capabilities(api, &workdir)?) .intersection(ticket_target_capabilities_for_workdir( api, worker, &workdir, )?); @@ -13937,9 +14172,7 @@ async fn scoped_attach_current_worker_workdir( code: "external_workdir_unavailable".to_string(), message: "External Workdir grant is unavailable".to_string(), })?; - let grant_expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - .map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now()) - .unwrap_or(true); + let grant_expired = external_workdir_grant_is_expired(&grant)?; let online = api .external_workdir_providers .lock() @@ -13949,7 +14182,9 @@ async fn scoped_attach_current_worker_workdir( connection.generation == grant.generation && connection.provider_instance_id == grant.provider_instance_id && connection.workdir_id == workdir.workdir_id - && connection.expires_at > Utc::now() + && connection + .expires_at + .is_none_or(|expires_at| expires_at > Utc::now()) }); if grant.status != "online" || grant_expired || !online { return Err(Error::RuntimeOperationFailed { @@ -16602,13 +16837,32 @@ async fn cleanup_runtime_working_directory( .map_err(ApiError::from) } +fn external_workdir_expires_at( + grant: &ExternalWorkdirGrantRecord, +) -> Result>> { + grant + .expires_at + .as_deref() + .map(|value| { + chrono::DateTime::parse_from_rfc3339(value) + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| Error::Store("External Workdir grant has invalid expiry".to_string())) + }) + .transpose() +} + +fn external_workdir_grant_is_expired(grant: &ExternalWorkdirGrantRecord) -> Result { + Ok(external_workdir_expires_at(grant)?.is_some_and(|expires_at| expires_at <= Utc::now())) +} + fn schedule_external_workdir_expiry( api: &WorkspaceApi, grant: &ExternalWorkdirGrantRecord, ) -> Result<()> { - let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - .map_err(|_| Error::Store("External Workdir grant has invalid expiry".to_string()))? - .with_timezone(&Utc); + let Some(expires_at) = external_workdir_expires_at(grant)? else { + cancel_external_workdir_expiry(api, &grant.grant_id); + return Ok(()); + }; let grant_id = grant.grant_id.clone(); let task_api = api.clone(); let task_grant_id = grant_id.clone(); @@ -16689,9 +16943,9 @@ async fn fence_external_workdir_grant_expired( if matches!(grant.status.as_str(), "revoked" | "expired") { return Ok(grant); } - let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - .map(|value| value.with_timezone(&Utc)) - .map_err(|_| Error::Store("External Workdir grant has invalid expiry".to_string()))?; + let Some(expires_at) = external_workdir_expires_at(&grant)? else { + return Ok(grant); + }; if expires_at > Utc::now() { return Ok(grant); } @@ -16766,15 +17020,12 @@ async fn scoped_create_external_workdir_grant( "External Workdir display name must be between 1 and 100 bytes and contain no control characters".to_string(), ).into()); } - if !request.read_only { + if request + .ttl_seconds + .is_some_and(|ttl_seconds| !(60..=86_400).contains(&ttl_seconds)) + { return Err(Error::InvalidInput( - "External Workdir grants currently require read_only=true".to_string(), - ) - .into()); - } - if !(60..=86_400).contains(&request.ttl_seconds) { - return Err(Error::InvalidInput( - "External Workdir ttl_seconds must be between 60 and 86400".to_string(), + "External Workdir ttl_seconds must be omitted or between 60 and 86400".to_string(), ) .into()); } @@ -16782,8 +17033,14 @@ async fn scoped_create_external_workdir_grant( .map_err(Error::InvalidInput)?; let now = Utc::now(); let created_at = now.to_rfc3339_opts(SecondsFormat::Nanos, true); - let expires_at = (now + Duration::seconds(request.ttl_seconds as i64)) - .to_rfc3339_opts(SecondsFormat::Nanos, true); + let expires_at = request + .ttl_seconds + .map(|ttl_seconds| now + Duration::seconds(ttl_seconds as i64)) + .map(|expires_at| expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true)); + let permissions = match request.permission { + server_api::ExternalWorkdirPermission::ReadOnly => "read_only", + server_api::ExternalWorkdirPermission::ReadWrite => "read_write", + }; let grant_id = format!("ewg-{}", Uuid::now_v7()); let workdir_id = format!("external-{}", Uuid::now_v7()); let grant = ExternalWorkdirGrantRecord { @@ -16792,7 +17049,7 @@ async fn scoped_create_external_workdir_grant( workdir_id: workdir_id.clone(), provider_instance_id: request.provider_instance_id, display_name: display_name.to_string(), - permissions: "read_only".to_string(), + permissions: permissions.to_string(), created_by: actor.account_id, created_at: created_at.clone(), expires_at: expires_at.clone(), @@ -16836,7 +17093,10 @@ fn external_workdir_grant_response( working_directory_id: grant.workdir_id.clone(), provider_instance_id: grant.provider_instance_id.clone(), display_name: grant.display_name.clone(), - permissions: grant.permissions.clone(), + permission: match grant.permissions.as_str() { + "read_write" => server_api::ExternalWorkdirPermission::ReadWrite, + _ => server_api::ExternalWorkdirPermission::ReadOnly, + }, expires_at: grant.expires_at.clone(), generation: grant.generation, status: grant.status.clone(), @@ -16859,10 +17119,7 @@ async fn scoped_external_workdir_provider_ws( "External Workdir provider connection must use the grant creator's authenticated client".to_string(), ).into()); } - let now = Utc::now(); - let expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - .map(|expires_at| expires_at.with_timezone(&Utc) <= now) - .unwrap_or(true); + let expired = external_workdir_grant_is_expired(&grant)?; if expired { grant = fence_external_workdir_grant_expired(&api, &grant_id).await?; } @@ -16916,10 +17173,17 @@ async fn serve_external_workdir_provider( let _ = socket.close().await; return; }; + let expected_capabilities = match external_workdir_capabilities(&initial_grant.permissions) { + Ok(capabilities) => capabilities, + Err(_) => { + let _ = socket.close().await; + return; + } + }; if registration.grant_id.as_str() != initial_grant.grant_id || registration.workdir_id.as_str() != initial_grant.workdir_id || registration.provider_instance_id.as_str() != initial_grant.provider_instance_id - || registration.capabilities != workdir::WorkdirSessionCapabilities::READ_ONLY + || registration.capabilities != expected_capabilities || registration.read_limits != workdir::BoundedReadLimits::EXTERNAL_DEFAULT || registration.generation == 0 { @@ -16972,8 +17236,8 @@ async fn serve_external_workdir_provider( let _ = socket.close().await; return; } - let expires_at = match chrono::DateTime::parse_from_rfc3339(&initial_grant.expires_at) { - Ok(value) => value.with_timezone(&Utc), + let expires_at = match external_workdir_expires_at(&initial_grant) { + Ok(value) => value, Err(_) => { let _ = socket.close().await; return; @@ -17226,11 +17490,8 @@ async fn scoped_get_external_workdir_grant( ) .into()); } - let now = Utc::now(); let expired = !matches!(grant.status.as_str(), "revoked" | "expired") - && chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - .map(|expires_at| expires_at.with_timezone(&Utc) <= now) - .unwrap_or(true); + && external_workdir_grant_is_expired(&grant)?; if expired { grant = fence_external_workdir_grant_expired(&api, &grant_id).await?; } @@ -21663,7 +21924,7 @@ fn finalize_spawned_worker_workdir_attachments( worker_record, attachment.alias.as_str(), &summary.working_directory_id, - workdir_source_capabilities(&workdir_record), + workdir_source_capabilities(api, &workdir_record)?, None, ), )?; @@ -25809,8 +26070,7 @@ mod tests { #[test] fn worker_projection_does_not_expose_unavailable_snapshot_as_current() { - let availability = - protocol::subscription::SubscriptionWorkerAvailability::Unavailable; + let availability = protocol::subscription::SubscriptionWorkerAvailability::Unavailable; let worker_ref = RuntimeWorkerRef::new("runtime-a", "worker-a"); let projection = WorkerRegistryProjectionRecord { registry: WorkerRegistryRecord { @@ -25860,8 +26120,7 @@ mod tests { let mut observed_stopped = projection; let observation = observed_stopped.observation.as_mut().unwrap(); - observation.availability = - protocol::subscription::SubscriptionWorkerAvailability::Observed; + observation.availability = protocol::subscription::SubscriptionWorkerAvailability::Observed; observation.worker.availability = protocol::subscription::SubscriptionWorkerAvailability::Observed; observation.worker.state = protocol::subscription::SubscriptionWorkerState::Stopped; @@ -25883,7 +26142,7 @@ mod tests { workdir_id: "external-a".to_string(), provider_instance_id: "provider-a".to_string(), generation: 4, - expires_at: Utc::now() + Duration::minutes(1), + expires_at: Some(Utc::now() + Duration::minutes(1)), capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, admission: Arc::new(tokio::sync::Semaphore::new(16)), sender, @@ -25939,6 +26198,70 @@ mod tests { )); } + #[tokio::test] + async fn external_provider_read_write_session_routes_files_but_never_commands() { + let (sender, mut commands) = tokio::sync::mpsc::channel(1); + let connection = Arc::new(ExternalProviderConnection { + grant_id: "grant-rw".to_string(), + workdir_id: "external-rw".to_string(), + provider_instance_id: "provider-rw".to_string(), + generation: 2, + expires_at: None, + capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE, + admission: Arc::new(tokio::sync::Semaphore::new(16)), + sender, + }); + let session = Arc::new(ExternalProviderWorkdirSession::new(connection, None)); + let writer = { + let session = session.clone(); + tokio::spawn(async move { + session + .write(workdir::WriteRequest { + path: workdir::WorkdirPath::new("created.txt").unwrap(), + content: b"created".to_vec(), + expected_hash: None, + }) + .await + }) + }; + let Some(ExternalProviderCommand::Operation { + operation, + response, + .. + }) = commands.recv().await + else { + panic!("expected provider write operation"); + }; + assert!(matches!(operation, WorkdirSessionOperation::Write(_))); + response + .send(Ok(WorkdirSessionOperationResult::Write( + workdir::WriteResult { + bytes_written: 7, + created: true, + }, + ))) + .unwrap(); + assert!(writer.await.unwrap().unwrap().created); + + let command = session + .start_command(workdir::CommandRequest { + command: "touch forbidden".to_string(), + timeout_secs: 1, + output_limit: 1024, + cwd: workdir::WorkdirPath::root(), + spill_dir: None, + tool_call_id: None, + }) + .await; + assert!(matches!( + command, + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Command + )) + )); + assert!(commands.try_recv().is_err()); + } + #[tokio::test] async fn backend_expiry_fences_offline_grant_and_persists_cleanup_completion() { let dir = tempfile::tempdir().unwrap(); @@ -25955,8 +26278,9 @@ mod tests { permissions: "read_only".to_string(), created_by: test_browser_request_actor().account_id, created_at: created_at.clone(), - expires_at: (now + Duration::milliseconds(50)) - .to_rfc3339_opts(SecondsFormat::Nanos, true), + expires_at: Some( + (now + Duration::milliseconds(50)).to_rfc3339_opts(SecondsFormat::Nanos, true), + ), generation: 1, status: "offline".to_string(), updated_at: created_at.clone(), @@ -26055,7 +26379,9 @@ mod tests { permissions: "read_only".to_string(), created_by: test_browser_request_actor().account_id, created_at: created_at.clone(), - expires_at: (now + Duration::minutes(5)).to_rfc3339_opts(SecondsFormat::Nanos, true), + expires_at: Some( + (now + Duration::minutes(5)).to_rfc3339_opts(SecondsFormat::Nanos, true), + ), generation: 1, status: "online".to_string(), updated_at: created_at.clone(), @@ -26260,7 +26586,7 @@ mod tests { permissions: "read_only".to_string(), created_by: test_browser_request_actor().account_id, created_at: now.clone(), - expires_at: expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true), + expires_at: Some(expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true)), generation: 1, status: "pending".to_string(), updated_at: now.clone(), @@ -26307,7 +26633,7 @@ mod tests { workdir_id: grant.workdir_id.clone(), provider_instance_id: grant.provider_instance_id.clone(), generation: 1, - expires_at, + expires_at: Some(expires_at), capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, admission: Arc::new(tokio::sync::Semaphore::new(16)), sender, @@ -26399,8 +26725,8 @@ mod tests { Json(ExternalWorkdirGrantCreateRequest { provider_instance_id: "provider-a".to_string(), display_name: "Session logs".to_string(), - ttl_seconds: 600, - read_only: true, + ttl_seconds: Some(600), + permission: server_api::ExternalWorkdirPermission::ReadWrite, }), ) .await @@ -26429,7 +26755,7 @@ mod tests { .id() .clone(), generation, - capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE, read_limits: workdir::BoundedReadLimits::EXTERNAL_DEFAULT, }, }) @@ -26525,14 +26851,27 @@ mod tests { .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) .unwrap(); assert_eq!(links.len(), 2); - assert!( - links.iter().all(|link| { - link.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY - }) + let repository_capabilities = links + .iter() + .find(|link| link.alias == "repository") + .unwrap() + .capabilities; + let external_capabilities = links + .iter() + .find(|link| link.alias == "session-logs") + .unwrap() + .capabilities; + assert_eq!( + repository_capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + assert_eq!( + external_capabilities, + workdir::WorkdirSessionCapabilities::READ_WRITE ); let provider_root = tempfile::tempdir().unwrap(); fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap(); - let local_session = workdir::LocalWorkdirSession::external_read_only( + let local_session = workdir::LocalWorkdirSession::external_read_write( workdir::Workdir::new(&grant.working_directory_id), provider_root.path(), workdir::BoundedReadLimits::EXTERNAL_DEFAULT, @@ -26546,7 +26885,7 @@ mod tests { .unwrap(); assert_eq!( broker_session.capabilities(), - workdir::WorkdirSessionCapabilities::READ_ONLY + workdir::WorkdirSessionCapabilities::READ_WRITE ); drop(session_guard); let read = { @@ -26596,7 +26935,143 @@ mod tests { )) .await .unwrap(); - assert_eq!(read.await.unwrap().unwrap().bytes, b"one\ntwo\n"); + let read_result = read.await.unwrap().unwrap(); + assert_eq!(read_result.bytes, b"one\ntwo\n"); + + let edit = { + let broker_session = broker_session.clone(); + let expected_hash = read_result.content_hash; + tokio::spawn(async move { + broker_session + .edit(workdir::EditRequest { + path: workdir::WorkdirPath::new("events.jsonl").unwrap(), + old_string: "two".to_string(), + new_string: "changed".to_string(), + replace_all: false, + expected_hash, + }) + .await + }) + }; + let (operation_id, operation) = loop { + let text = second.next().await.unwrap().unwrap().into_text().unwrap(); + let frame = serde_json::from_str::(&text).unwrap(); + if let ExternalWorkdirServerMessage::Operation { + generation: 2, + operation_id, + operation, + } = frame.message + { + break (operation_id, operation); + } + }; + assert!(matches!( + operation.as_inner(), + WorkdirSessionOperation::Edit(_) + )); + let result = workdir::http::dispatch_workdir_session_operation( + &local_session, + operation.into_inner(), + ) + .await + .unwrap() + .try_into() + .unwrap(); + second + .send(Message::Text( + serde_json::to_string(&ExternalWorkdirProviderFrame::current( + ExternalWorkdirProviderMessage::OperationResult { + generation: 2, + operation_id, + outcome: ExternalWorkdirOperationOutcome::Completed { result }, + }, + )) + .unwrap() + .into(), + )) + .await + .unwrap(); + assert_eq!(edit.await.unwrap().unwrap().replacements, 1); + + let write = { + let broker_session = broker_session.clone(); + tokio::spawn(async move { + broker_session + .write(workdir::WriteRequest { + path: workdir::WorkdirPath::new("created.txt").unwrap(), + content: b"created".to_vec(), + expected_hash: None, + }) + .await + }) + }; + let (operation_id, operation) = loop { + let text = second.next().await.unwrap().unwrap().into_text().unwrap(); + let frame = serde_json::from_str::(&text).unwrap(); + if let ExternalWorkdirServerMessage::Operation { + generation: 2, + operation_id, + operation, + } = frame.message + { + break (operation_id, operation); + } + }; + assert!(matches!( + operation.as_inner(), + WorkdirSessionOperation::Write(_) + )); + let result = workdir::http::dispatch_workdir_session_operation( + &local_session, + operation.into_inner(), + ) + .await + .unwrap() + .try_into() + .unwrap(); + second + .send(Message::Text( + serde_json::to_string(&ExternalWorkdirProviderFrame::current( + ExternalWorkdirProviderMessage::OperationResult { + generation: 2, + operation_id, + outcome: ExternalWorkdirOperationOutcome::Completed { result }, + }, + )) + .unwrap() + .into(), + )) + .await + .unwrap(); + assert!(write.await.unwrap().unwrap().created); + assert_eq!( + fs::read_to_string(provider_root.path().join("events.jsonl")).unwrap(), + "one\nchanged\n" + ); + assert_eq!( + fs::read_to_string(provider_root.path().join("created.txt")).unwrap(), + "created" + ); + let command_error = broker_session + .start_command(workdir::CommandRequest { + command: "touch forbidden".to_string(), + timeout_secs: 1, + output_limit: 1024, + cwd: workdir::WorkdirPath::root(), + spill_dir: None, + tool_call_id: None, + }) + .await + .unwrap_err(); + assert!( + matches!( + command_error, + workdir::WorkdirError::Unsupported(workdir::WorkdirSessionCapability::Command) + | workdir::WorkdirError::UnsupportedOperation(_) + | workdir::WorkdirError::Denied(_) + ), + "unexpected command error: {command_error:?}" + ); let Json(revoked) = scoped_revoke_external_workdir_grant( State(api.clone()), @@ -26638,7 +27113,7 @@ mod tests { workdir_id: "external-a".to_string(), provider_instance_id: "provider-a".to_string(), generation: 2, - expires_at: Utc::now() + Duration::minutes(1), + expires_at: Some(Utc::now() + Duration::minutes(1)), capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, admission: Arc::new(tokio::sync::Semaphore::new(16)), sender, @@ -26791,8 +27266,7 @@ mod tests { identity: TEST_WORKSPACE_ID.to_string(), workspace_id: Some(TEST_WORKSPACE_ID.to_string()), }, - availability: - protocol::subscription::SubscriptionWorkerAvailability::Observed, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, state: "idle".to_string(), worker_state: None, last_seen_at: None, @@ -38590,8 +39064,7 @@ mod tests { identity: "workspace-test".to_string(), workspace_id: Some(TEST_WORKSPACE_ID.to_string()), }, - availability: - protocol::subscription::SubscriptionWorkerAvailability::Observed, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, state: "idle".to_string(), worker_state: None, last_seen_at: None, @@ -38689,8 +39162,7 @@ mod tests { identity: "workspace-test".to_string(), workspace_id: Some(TEST_WORKSPACE_ID.to_string()), }, - availability: - protocol::subscription::SubscriptionWorkerAvailability::Observed, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, state: "idle".to_string(), worker_state: None, last_seen_at: None, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 1f9859ba7..da4eaaa37 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 66; +const LATEST_SCHEMA_VERSION: i64 = 67; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -48,6 +48,8 @@ const EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME: &str = "durable External Workdir expiry cleanup state"; const TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME: &str = "Ticket target collection and Workdir attachment capabilities"; +const EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME: &str = + "External Workdir read-write access and optional expiry"; const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7; const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" @@ -175,6 +177,11 @@ const MIGRATIONS: &[Migration] = &[ name: TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME, apply: migrate_ticket_targets_and_workdir_capabilities_v65_to_v66, }, + Migration { + version: 67, + name: EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME, + apply: migrate_external_workdir_access_and_optional_expiry_v66_to_v67, + }, ]; #[derive(Clone, Copy)] @@ -870,7 +877,7 @@ pub struct ExternalWorkdirGrantRecord { pub permissions: String, pub created_by: String, pub created_at: String, - pub expires_at: String, + pub expires_at: Option, pub generation: u64, pub status: String, pub updated_at: String, @@ -6883,9 +6890,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![record.workspace_id, record.ticket_id], |row| Ok((row.get(0)?, row.get(1)?)), )?; - if target_count == 0 || read_write_count != 1 { + if target_count == 0 || read_write_count == 0 { return Err(Error::TicketAssignmentConflict( - "manual Coder assignment requires exactly one read_write repository target" + "manual Coder assignment requires at least one read_write repository target" .to_string(), )); } @@ -7754,7 +7761,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { WHERE workspace_id = ?1 AND grant_id = ?2 AND provider_instance_id = ?3 AND ((status = 'pending' AND generation = ?4) OR (status = 'offline' AND generation + 1 = ?4)) - AND expires_at > ?5"#, + AND (expires_at IS NULL OR expires_at > ?5)"#, params![ workspace_id, grant_id, @@ -7827,12 +7834,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { SET status = 'expired', updated_at = ?2, cleanup_state = 'pending', cleanup_error = NULL, cleanup_updated_at = ?2 WHERE workspace_id = ?1 AND status IN ('pending', 'online', 'offline') - AND expires_at <= ?2", + AND expires_at IS NOT NULL AND expires_at <= ?2", params![workspace_id, now], )?; tx.execute( "UPDATE external_workdir_grants SET status = 'offline', updated_at = ?2 - WHERE workspace_id = ?1 AND status = 'online' AND expires_at > ?2", + WHERE workspace_id = ?1 AND status = 'online' + AND (expires_at IS NULL OR expires_at > ?2)", params![workspace_id, now], )?; tx.execute( @@ -9130,9 +9138,12 @@ fn encode_workdir_link_capabilities( Ok("all") } else if capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY { Ok("read_only") + } else if capabilities == workdir::WorkdirSessionCapabilities::READ_WRITE { + Ok("read_write") } else { Err(Error::InvalidInput( - "Workdir attachment capabilities must be exactly all or read_only".to_string(), + "Workdir attachment capabilities must be exactly all, read_only, or read_write" + .to_string(), )) } } @@ -9144,6 +9155,7 @@ fn decode_workdir_link_capabilities( match value { "all" => Ok(workdir::WorkdirSessionCapabilities::ALL), "read_only" => Ok(workdir::WorkdirSessionCapabilities::READ_ONLY), + "read_write" => Ok(workdir::WorkdirSessionCapabilities::READ_WRITE), _ => Err(rusqlite::Error::FromSqlConversionFailure( column, rusqlite::types::Type::Text, @@ -11957,6 +11969,135 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re } } +fn migrate_external_workdir_access_and_optional_expiry_v66_to_v67(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 66 { + return Err(Error::Store(format!( + "expected schema version 66 before {EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME} migration, found {current}" + ))); + } + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE external_workdir_grants_v67 ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + provider_instance_id TEXT NOT NULL, + display_name TEXT NOT NULL, + permissions TEXT NOT NULL CHECK (permissions IN ('read_only', 'read_write')), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + generation INTEGER NOT NULL CHECK (generation > 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), + updated_at TEXT NOT NULL, + cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')), + cleanup_error TEXT, + cleanup_updated_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, workdir_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED + ); + INSERT INTO external_workdir_grants_v67 ( + grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at, + cleanup_state, cleanup_error, cleanup_updated_at + ) SELECT grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at, + cleanup_state, cleanup_error, cleanup_updated_at + FROM external_workdir_grants; + DROP TABLE external_workdir_grants; + ALTER TABLE external_workdir_grants_v67 RENAME TO external_workdir_grants; + CREATE INDEX idx_external_workdir_grants_status_expiry + ON external_workdir_grants(workspace_id, status, expires_at); + CREATE TABLE worker_workdir_links_v67 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + capabilities TEXT NOT NULL CHECK (capabilities IN ('all', 'read_only', 'read_write')), + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links_v67 ( + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, + linked_at, unlinked_at + ) SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, + linked_at, unlinked_at + FROM worker_workdir_links; + DROP TABLE worker_workdir_links; + ALTER TABLE worker_workdir_links_v67 RENAME TO worker_workdir_links; + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + "#, + )?; + let violations = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if violations != 0 { + return Err(Error::Store(format!( + "schema-67 migration left {violations} foreign-key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![ + 67_i64, + EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME + ], + )?; + tx.commit()?; + Ok(()) + })(); + let restore = if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = ON;") + .map_err(Error::from) + } else { + Ok(()) + }; + match (result, restore) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch(include_str!("latest_schema.sql"))?; @@ -12019,7 +12160,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { ON typed_ticket_targets(workspace_id, repository_key, ticket_id); ALTER TABLE worker_workdir_links ADD COLUMN capabilities TEXT NOT NULL - CHECK (capabilities IN ('all', 'read_only')); + CHECK (capabilities IN ('all', 'read_only', 'read_write')); "#, )?; ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { @@ -12745,10 +12886,10 @@ mod tests { workdir_id: "external-a".to_string(), provider_instance_id: "provider-a".to_string(), display_name: "Session logs".to_string(), - permissions: "read_only".to_string(), + permissions: "read_write".to_string(), created_by: "owner".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), - expires_at: "2099-01-01T00:00:00Z".to_string(), + expires_at: None, generation: 1, status: "pending".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), @@ -12775,6 +12916,12 @@ mod tests { store .create_external_workdir_grant(&grant, &workdir) .unwrap(); + let persisted_grant = store + .get_external_workdir_grant("workspace-a", "grant-a") + .unwrap() + .unwrap(); + assert_eq!(persisted_grant.permissions, "read_write"); + assert_eq!(persisted_grant.expires_at, None); store .record_external_workdir_audit( "workspace-a", @@ -12858,6 +13005,14 @@ mod tests { .unwrap() .is_empty() ); + assert_eq!( + store + .get_external_workdir_grant("workspace-a", "grant-a") + .unwrap() + .unwrap() + .status, + "offline" + ); assert!( !store .activate_external_workdir_grant( @@ -14074,6 +14229,10 @@ mod tests { version: 66, name: TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 67, + name: EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME.to_string(), + }, ] ); @@ -14132,6 +14291,10 @@ mod tests { 66, TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME.to_string(), ), + ( + 67, + EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME.to_string(), + ), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -14202,7 +14365,9 @@ mod tests { .iter() .map(|migration| migration.version) .collect::>(), - vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66] + vec![ + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67 + ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -14210,7 +14375,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 17); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 18); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index b46b54a6d..e0c0ce435 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -622,6 +622,8 @@ fn parse_workdir_args( let mut display_name = None; let mut ttl = None; let mut read_only = false; + let mut read_write = false; + let mut non_interactive = false; let mut index = 0; while index < args.len() { match args[index].as_str() { @@ -658,9 +660,10 @@ fn parse_workdir_args( if ttl.is_some() { return Err(ParseError("--ttl may only be provided once".to_string())); } - ttl = Some(parse_share_ttl(required_option_value( - args, index, "--ttl", - )?)?); + ttl = Some( + workdir_share::parse_ttl(required_option_value(args, index, "--ttl")?) + .map_err(|error| ParseError(format!("--ttl {error}")))?, + ); index += 2; } "--read-only" => { @@ -672,6 +675,24 @@ fn parse_workdir_args( read_only = true; index += 1; } + "--read-write" => { + if read_write { + return Err(ParseError( + "--read-write may only be provided once".to_string(), + )); + } + read_write = true; + index += 1; + } + "--non-interactive" => { + if non_interactive { + return Err(ParseError( + "--non-interactive may only be provided once".to_string(), + )); + } + non_interactive = true; + index += 1; + } "--help" | "-h" => return Ok(Mode::WorkdirHelp), option if option.starts_with('-') => { return Err(ParseError(format!( @@ -689,49 +710,33 @@ fn parse_workdir_args( } } } - if !read_only { + if read_only && read_write { return Err(ParseError( - "yoi workdir share requires --read-only".to_string(), + "--read-only conflicts with --read-write".to_string(), )); } - let workspace_id = workspace_id - .ok_or_else(|| ParseError("yoi workdir share requires --workspace-id".to_string()))?; - let backend_url = resolve_backend_url(backend_url, Some(&workspace_id))?; + if non_interactive && workspace_id.is_none() { + return Err(ParseError( + "--non-interactive requires --workspace-id".to_string(), + )); + } + let backend_url = resolve_backend_url(backend_url, workspace_id.as_deref())?; Ok(Mode::WorkdirShare(workdir_share::WorkdirShareOptions { path: path .ok_or_else(|| ParseError("yoi workdir share requires a directory path".to_string()))?, workspace_id, backend_url, - display_name: display_name - .ok_or_else(|| ParseError("yoi workdir share requires --display-name".to_string()))?, - ttl: ttl.ok_or_else(|| ParseError("yoi workdir share requires --ttl".to_string()))?, + display_name: display_name.unwrap_or_else(|| "External Workdir".to_string()), + ttl, + permission: if read_write { + server_api::ExternalWorkdirPermission::ReadWrite + } else { + server_api::ExternalWorkdirPermission::ReadOnly + }, + non_interactive, })) } -fn parse_share_ttl(value: &str) -> Result { - let (number, multiplier) = if let Some(value) = value.strip_suffix('h') { - (value, 60 * 60) - } else if let Some(value) = value.strip_suffix('m') { - (value, 60) - } else if let Some(value) = value.strip_suffix('s') { - (value, 1) - } else { - return Err(ParseError( - "--ttl must use an h, m, or s suffix (for example 1h)".to_string(), - )); - }; - let amount = number.parse::().map_err(|_| { - ParseError("--ttl must contain a positive integer and h, m, or s suffix".to_string()) - })?; - let seconds = amount - .checked_mul(multiplier) - .ok_or_else(|| ParseError("--ttl is too large".to_string()))?; - if !(60..=86_400).contains(&seconds) { - return Err(ParseError("--ttl must be between 60s and 24h".to_string())); - } - Ok(Duration::from_secs(seconds)) -} - fn parse_init_args( args: &[String], explicit_backend_url: Option, @@ -1857,7 +1862,7 @@ Usage: yoi --backend [--workspace-id ] panel yoi [--backend ] init --display-name --repository-key [--repository ] [--default-ref ] yoi [--backend ] login [--no-wait] - yoi [--backend ] workdir share --workspace-id --read-only --ttl --display-name + yoi [--backend ] workdir share [--workspace-id ] [--read-only|--read-write] [--ttl ] [--display-name ] [--non-interactive] yoi [OPTIONS] Target selection: @@ -1883,7 +1888,7 @@ Console options: Host commands: yoi init Register the current Git repository as a new Backend Workspace. - yoi workdir share Share a temporary read-only client-hosted External Workdir. + yoi workdir share Interactively share a client-hosted External Workdir. keys Manage local model/API keys setup-model Configure a local model provider worker [WORKER_OPTIONS] Run the direct Worker process entrypoint @@ -1903,7 +1908,7 @@ Options: fn print_workdir_help() { println!( - "yoi workdir share\n\nUsage:\n yoi [--backend ] workdir share --workspace-id --read-only --ttl --display-name \n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nNo host path is sent to the Backend. TTL accepts s, m, or h suffixes from 60s through 24h.\nCtrl-C explicitly revokes the grant before exit.\n" + "yoi workdir share\n\nUsage:\n yoi [--backend ] workdir share [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, permission, and TTL, then explicitly confirm before any grant is created.\nRead-only is the default permission. --read-write enables the existing filesystem Write/Edit operations only; Bash, command, and exec remain disabled.\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 Preselect a Workspace; required with --non-interactive\n --read-only Explicitly select the default read-only permission\n --read-write Allow filesystem Write/Edit operations (never commands)\n --ttl Automatic expiry; omission is unlimited\n --display-name Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n" ); } @@ -1963,11 +1968,12 @@ mod tests { let args = vec![ "share".to_string(), "./session-log".to_string(), - "--read-only".to_string(), + "--read-write".to_string(), "--ttl".to_string(), "90m".to_string(), "--display-name".to_string(), "session-analysis".to_string(), + "--non-interactive".to_string(), ]; let target = TargetSelection { backend_url: Some("https://backend.example".to_string()), @@ -1978,56 +1984,82 @@ mod tests { panic!("expected Workdir share mode"); }; assert_eq!(options.path, PathBuf::from("./session-log")); - assert_eq!(options.workspace_id, "workspace-a"); + assert_eq!(options.workspace_id.as_deref(), Some("workspace-a")); assert_eq!(options.backend_url, "https://backend.example"); assert_eq!(options.display_name, "session-analysis"); - assert_eq!(options.ttl, Duration::from_secs(5_400)); + assert_eq!(options.ttl, Some(Duration::from_secs(5_400))); + assert_eq!( + options.permission, + server_api::ExternalWorkdirPermission::ReadWrite + ); + assert!(options.non_interactive); } #[test] - fn workdir_share_fails_closed_without_explicit_read_only_workspace_or_valid_ttl() { - let base = vec![ - "share".to_string(), - ".".to_string(), - "--ttl".to_string(), - "1h".to_string(), - "--display-name".to_string(), - "logs".to_string(), - ]; + fn workdir_share_defaults_to_interactive_read_only_and_unlimited() { + let args = vec!["share".to_string(), ".".to_string()]; + let target = TargetSelection { + backend_url: Some("https://backend.example".to_string()), + ..TargetSelection::default() + }; + let Mode::WorkdirShare(options) = parse_workdir_args(&args, &target).unwrap() else { + panic!("expected Workdir share mode"); + }; + assert_eq!(options.workspace_id, None); + assert_eq!(options.display_name, "External Workdir"); + assert_eq!(options.ttl, None); + assert_eq!( + options.permission, + server_api::ExternalWorkdirPermission::ReadOnly + ); + assert!(!options.non_interactive); + } + + #[test] + fn workdir_share_rejects_invalid_or_conflicting_automation_options() { let target = TargetSelection { backend_url: Some("https://backend.example".to_string()), workspace_id: Some("workspace-a".to_string()), ..TargetSelection::default() }; - assert!( - parse_workdir_args(&base, &target) - .err() - .unwrap() - .0 - .contains("--read-only") - ); - - let mut invalid_ttl = base.clone(); - invalid_ttl.push("--read-only".to_string()); - invalid_ttl[3] = "25h".to_string(); + let invalid_ttl = vec![ + "share".to_string(), + ".".to_string(), + "--ttl".to_string(), + "25h".to_string(), + ]; assert!( parse_workdir_args(&invalid_ttl, &target) - .err() - .unwrap() + .unwrap_err() .0 .contains("24h") ); + let conflict = vec![ + "share".to_string(), + ".".to_string(), + "--read-only".to_string(), + "--read-write".to_string(), + ]; + assert!( + parse_workdir_args(&conflict, &target) + .unwrap_err() + .0 + .contains("conflicts") + ); + let missing_workspace = TargetSelection { backend_url: Some("https://backend.example".to_string()), ..TargetSelection::default() }; - let mut valid = base; - valid.push("--read-only".to_string()); + let automation = vec![ + "share".to_string(), + ".".to_string(), + "--non-interactive".to_string(), + ]; assert!( - parse_workdir_args(&valid, &missing_workspace) - .err() - .unwrap() + parse_workdir_args(&automation, &missing_workspace) + .unwrap_err() .0 .contains("--workspace-id") ); diff --git a/crates/yoi/src/workdir_share.rs b/crates/yoi/src/workdir_share.rs index 1786e7524..44d104abf 100644 --- a/crates/yoi/src/workdir_share.rs +++ b/crates/yoi/src/workdir_share.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::io::{BufRead, IsTerminal, Write}; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -20,16 +21,17 @@ use workdir::external::{ use workdir::http::{WorkdirTransportError, dispatch_workdir_session_operation}; use workdir::{ BoundedReadLimits, ExternalWorkdirRoot, LocalWorkdirSession, Workdir, WorkdirSession, - WorkdirSessionCapabilities, }; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WorkdirShareOptions { pub path: PathBuf, - pub workspace_id: String, + pub workspace_id: Option, pub backend_url: String, pub display_name: String, - pub ttl: Duration, + pub ttl: Option, + pub permission: server_api::ExternalWorkdirPermission, + pub non_interactive: bool, } const PROVIDER_OPERATION_TIMEOUT: Duration = Duration::from_secs(30); @@ -126,7 +128,254 @@ fn authenticated_server_api_client( Ok(ServerApiClient::with_client(base_url, http_client)) } +fn permission_label(permission: server_api::ExternalWorkdirPermission) -> &'static str { + match permission { + server_api::ExternalWorkdirPermission::ReadOnly => "read-only", + server_api::ExternalWorkdirPermission::ReadWrite => { + "read-write (files only; commands disabled)" + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedWorkdirShareOptions { + path: PathBuf, + workspace_id: String, + backend_url: String, + display_name: String, + ttl: Option, + permission: server_api::ExternalWorkdirPermission, +} + +pub(crate) fn parse_ttl(value: &str) -> Result { + let (number, multiplier) = if let Some(value) = value.strip_suffix('h') { + (value, 60 * 60) + } else if let Some(value) = value.strip_suffix('m') { + (value, 60) + } else if let Some(value) = value.strip_suffix('s') { + (value, 1) + } else { + return Err("TTL must use an h, m, or s suffix (for example 1h)".to_string()); + }; + let amount = number + .parse::() + .map_err(|_| "TTL must contain a positive integer and h, m, or s suffix".to_string())?; + let seconds = amount + .checked_mul(multiplier) + .ok_or_else(|| "TTL is too large".to_string())?; + if !(60..=86_400).contains(&seconds) { + return Err("TTL must be between 60s and 24h".to_string()); + } + Ok(Duration::from_secs(seconds)) +} + +fn ttl_label(ttl: Option) -> String { + match ttl { + None => "unlimited (ends on stop, disconnect, or revoke)".to_string(), + Some(ttl) => format!("{}s", ttl.as_secs()), + } +} + +fn read_prompt_line( + input: &mut impl BufRead, + output: &mut impl Write, + prompt: &str, +) -> Result, String> { + write!(output, "{prompt}").map_err(|error| format!("failed to write prompt: {error}"))?; + output + .flush() + .map_err(|error| format!("failed to flush prompt: {error}"))?; + let mut line = String::new(); + let read = input + .read_line(&mut line) + .map_err(|error| format!("failed to read operator input: {error}"))?; + if read == 0 { + return Ok(None); + } + Ok(Some(line.trim().to_string())) +} + +fn configure_interactively( + mut options: WorkdirShareOptions, + workspaces: &[server_api::WorkspaceSummary], + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result, String> { + if workspaces.is_empty() { + return Err("Backend returned no accessible Workspaces".to_string()); + } + writeln!(output, "Select a Workspace:") + .map_err(|error| format!("failed to write prompt: {error}"))?; + for (index, workspace) in workspaces.iter().enumerate() { + writeln!( + output, + " {}) {} ({})", + index + 1, + workspace.display_name, + workspace.workspace_id + ) + .map_err(|error| format!("failed to write prompt: {error}"))?; + } + let initial_index = options + .workspace_id + .as_deref() + .map(|workspace_id| { + workspaces + .iter() + .position(|workspace| workspace.workspace_id == workspace_id) + .ok_or_else(|| { + format!( + "Workspace `{workspace_id}` is not present in the accessible Backend catalog" + ) + }) + }) + .transpose()? + .unwrap_or(0); + let Some(selection) = read_prompt_line( + input, + output, + &format!("Workspace [{}; q to cancel]: ", initial_index + 1), + )? + else { + return Ok(None); + }; + if matches!(selection.as_str(), "q" | "quit" | "cancel") { + return Ok(None); + } + let selected_index = if selection.is_empty() { + initial_index + } else { + selection + .parse::() + .ok() + .and_then(|index| index.checked_sub(1)) + .filter(|index| *index < workspaces.len()) + .ok_or_else(|| "Workspace selection is invalid".to_string())? + }; + options.workspace_id = Some(workspaces[selected_index].workspace_id.clone()); + + let current_permission = match options.permission { + server_api::ExternalWorkdirPermission::ReadOnly => "r", + server_api::ExternalWorkdirPermission::ReadWrite => "w", + }; + writeln!( + output, + "Permission: read-only or read-write files. Read-write never enables Bash/command/exec." + ) + .map_err(|error| format!("failed to write prompt: {error}"))?; + let Some(permission) = read_prompt_line( + input, + output, + &format!("Permission [r/w; current {current_permission}; q to cancel]: "), + )? + else { + return Ok(None); + }; + options.permission = match permission.as_str() { + "" => options.permission, + "r" | "read-only" | "read_only" => server_api::ExternalWorkdirPermission::ReadOnly, + "w" | "read-write" | "read_write" => server_api::ExternalWorkdirPermission::ReadWrite, + "q" | "quit" | "cancel" => return Ok(None), + _ => return Err("Permission must be read-only (`r`) or read-write (`w`)".to_string()), + }; + + writeln!(output, "TTL: {}", ttl_label(options.ttl)) + .map_err(|error| format!("failed to write prompt: {error}"))?; + let Some(ttl) = read_prompt_line( + input, + output, + "TTL [empty keeps current; `none` is unlimited; q to cancel]: ", + )? + else { + return Ok(None); + }; + options.ttl = match ttl.as_str() { + "" => options.ttl, + "none" | "unlimited" => None, + "q" | "quit" | "cancel" => return Ok(None), + value => Some(parse_ttl(value)?), + }; + + let workspace_id = options + .workspace_id + .clone() + .expect("interactive selection always assigns a Workspace"); + writeln!(output, "Share configuration:") + .and_then(|()| writeln!(output, " Workspace: {workspace_id}")) + .and_then(|()| { + writeln!( + output, + " Permission: {}", + permission_label(options.permission) + ) + }) + .and_then(|()| writeln!(output, " TTL: {}", ttl_label(options.ttl))) + .map_err(|error| format!("failed to write confirmation: {error}"))?; + let Some(confirm) = read_prompt_line(input, output, "Connect and create grant? [y/N]: ")? + else { + return Ok(None); + }; + if !matches!(confirm.as_str(), "y" | "yes") { + return Ok(None); + } + Ok(Some(ResolvedWorkdirShareOptions { + path: options.path, + workspace_id, + backend_url: options.backend_url, + display_name: options.display_name, + ttl: options.ttl, + permission: options.permission, + })) +} + +fn ensure_interactive_tty(stdin_is_tty: bool, stderr_is_tty: bool) -> Result<(), String> { + if stdin_is_tty && stderr_is_tty { + Ok(()) + } else { + Err( + "interactive sharing requires a TTY; use --non-interactive with --workspace-id for automation" + .to_string(), + ) + } +} + +async fn resolve_share_options( + options: WorkdirShareOptions, +) -> Result, String> { + if options.non_interactive { + let workspace_id = options.workspace_id.clone().ok_or_else(|| { + "non-interactive sharing requires --workspace-id; no Workspace is inferred from cwd or path" + .to_string() + })?; + return Ok(Some(ResolvedWorkdirShareOptions { + path: options.path, + workspace_id, + backend_url: options.backend_url, + display_name: options.display_name, + ttl: options.ttl, + permission: options.permission, + })); + } + ensure_interactive_tty( + std::io::stdin().is_terminal(), + std::io::stderr().is_terminal(), + )?; + let target = client::BackendWorkspaceCatalogTarget::new(options.backend_url.clone()); + let workspaces = client::list_backend_workspaces(&target) + .await + .map_err(|error| format!("failed to query Backend Workspace catalog: {error}"))?; + let stdin = std::io::stdin(); + let stderr = std::io::stderr(); + let mut input = stdin.lock(); + let mut output = stderr.lock(); + configure_interactively(options, &workspaces, &mut input, &mut output) +} + pub(crate) 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(()); + }; // Pin before creating remote authority: the Backend grant can never outlive // a failed local approval/open race, and subsequent path replacement cannot // redirect the provider. @@ -142,30 +391,53 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { ExternalWorkdirGrantCreateRequest { provider_instance_id: provider_instance_id.clone(), display_name: options.display_name.clone(), - ttl_seconds: options.ttl.as_secs(), - read_only: true, + ttl_seconds: options.ttl.map(|ttl| ttl.as_secs()), + permission: options.permission, }, ) .await .map_err(|error| format!("External Workdir grant request failed: {error}"))?; let limits = BoundedReadLimits::EXTERNAL_DEFAULT; - let session = LocalWorkdirSession::external_read_only_pinned( - Workdir::new(&grant.working_directory_id), - pinned_root, - limits, - ) + let session = match options.permission { + server_api::ExternalWorkdirPermission::ReadOnly => { + LocalWorkdirSession::external_read_only_pinned( + Workdir::new(&grant.working_directory_id), + pinned_root, + limits, + ) + } + server_api::ExternalWorkdirPermission::ReadWrite => { + LocalWorkdirSession::external_read_write_pinned( + Workdir::new(&grant.working_directory_id), + pinned_root, + limits, + ) + } + } .map_err(|error| error.to_string())?; println!("External Workdir grant: {}", grant.grant_id); println!("Logical Workdir: {}", grant.working_directory_id); println!("Display name: {}", grant.display_name); - println!("Permission: {}", grant.permissions); - println!("Expires at: {}", grant.expires_at); + println!("Permission: {}", permission_label(grant.permission)); + println!( + "Expires at: {}", + grant + .expires_at + .as_deref() + .unwrap_or("never (until stopped or revoked)") + ); - let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - .map_err(|_| "Backend returned an invalid External Workdir expiry".to_string())? - .with_timezone(&Utc); + let expires_at = grant + .expires_at + .as_deref() + .map(|expires_at| { + chrono::DateTime::parse_from_rfc3339(expires_at) + .map(|expires_at| expires_at.with_timezone(&Utc)) + .map_err(|_| "Backend returned an invalid External Workdir expiry".to_string()) + }) + .transpose()?; let mut generation = grant.generation; loop { let outcome = serve_provider_connection( @@ -181,7 +453,7 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { let outcome = match outcome { Ok(outcome) => outcome, Err(connection_error) => { - if Utc::now() >= expires_at { + if expires_at.is_some_and(|expires_at| Utc::now() >= expires_at) { return Err(format!( "External Workdir grant expired after provider connection failure: {connection_error}" )); @@ -257,7 +529,7 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { return Ok(()); } ProviderEnd::Disconnected => { - if Utc::now() >= expires_at { + if expires_at.is_some_and(|expires_at| Utc::now() >= expires_at) { return Err("External Workdir grant expired while disconnected".to_string()); } generation = generation @@ -278,7 +550,7 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { async fn serve_provider_connection( backend_client: &BackendApiClient, server_api_client: &ServerApiClient, - options: &WorkdirShareOptions, + options: &ResolvedWorkdirShareOptions, grant: &ExternalWorkdirGrantResponse, provider_instance_id: &str, session: &LocalWorkdirSession, @@ -314,7 +586,7 @@ async fn serve_provider_connection( .map_err(|error| error.to_string())?, workdir_id: session.workdir().id().clone(), generation, - capabilities: WorkdirSessionCapabilities::READ_ONLY, + capabilities: session.capabilities(), read_limits: BoundedReadLimits::EXTERNAL_DEFAULT, }, }); @@ -646,6 +918,109 @@ mod tests { use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; + fn share_options() -> WorkdirShareOptions { + WorkdirShareOptions { + path: PathBuf::from("."), + workspace_id: None, + backend_url: "https://backend.example".to_string(), + display_name: "External Workdir".to_string(), + ttl: None, + permission: server_api::ExternalWorkdirPermission::ReadOnly, + non_interactive: false, + } + } + + fn workspace(workspace_id: &str, display_name: &str) -> server_api::WorkspaceSummary { + server_api::WorkspaceSummary { + workspace_id: workspace_id.to_string(), + owner_account_id: "account-a".to_string(), + display_name: display_name.to_string(), + state: "active".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + #[test] + fn interactive_share_defaults_to_first_workspace_read_only_and_unlimited() { + let workspaces = [workspace("workspace-a", "Alpha")]; + let mut input = std::io::Cursor::new(b"\n\n\nyes\n".to_vec()); + let mut output = Vec::new(); + + let selected = + configure_interactively(share_options(), &workspaces, &mut input, &mut output) + .unwrap() + .unwrap(); + + assert_eq!(selected.workspace_id, "workspace-a"); + assert_eq!( + selected.permission, + server_api::ExternalWorkdirPermission::ReadOnly + ); + assert_eq!(selected.ttl, None); + assert!( + String::from_utf8(output) + .unwrap() + .contains("Connect and create grant? [y/N]") + ); + } + + #[test] + fn interactive_share_requires_explicit_read_write_selection_and_confirmation() { + let workspaces = [ + workspace("workspace-a", "Alpha"), + workspace("workspace-b", "Beta"), + ]; + let mut input = std::io::Cursor::new(b"2\nw\n15m\ny\n".to_vec()); + let mut output = Vec::new(); + + let selected = + configure_interactively(share_options(), &workspaces, &mut input, &mut output) + .unwrap() + .unwrap(); + + assert_eq!(selected.workspace_id, "workspace-b"); + assert_eq!( + selected.permission, + server_api::ExternalWorkdirPermission::ReadWrite + ); + assert_eq!(selected.ttl, Some(Duration::from_secs(900))); + } + + #[test] + fn interactive_share_cancellation_yields_no_grant_configuration() { + let workspaces = [workspace("workspace-a", "Alpha")]; + let mut declined = std::io::Cursor::new(b"\n\n\nno\n".to_vec()); + let mut output = Vec::new(); + assert!( + configure_interactively(share_options(), &workspaces, &mut declined, &mut output,) + .unwrap() + .is_none() + ); + + let mut eof = std::io::Cursor::new(Vec::::new()); + assert!( + configure_interactively(share_options(), &workspaces, &mut eof, &mut Vec::new()) + .unwrap() + .is_none() + ); + } + + #[test] + fn interactive_share_fails_closed_without_input_and_prompt_ttys() { + assert!(ensure_interactive_tty(true, true).is_ok()); + assert!( + ensure_interactive_tty(false, true) + .unwrap_err() + .contains("--non-interactive") + ); + assert!( + ensure_interactive_tty(true, false) + .unwrap_err() + .contains("--non-interactive") + ); + } + fn test_server_api_client(base_url: &str) -> ServerApiClient { let mut headers = reqwest::header::HeaderMap::new(); headers.insert( @@ -726,7 +1101,7 @@ mod tests { "working_directory_id": "workdir-a", "provider_instance_id": "provider-a", "display_name": "Shared files", - "permissions": "read_only", + "permission": "read_only", "expires_at": "2026-09-23T03:00:00Z", "generation": 7, "status": "pending" @@ -745,8 +1120,8 @@ mod tests { ExternalWorkdirGrantCreateRequest { provider_instance_id: "provider-a".to_string(), display_name: "Shared files".to_string(), - ttl_seconds: 600, - read_only: true, + ttl_seconds: Some(600), + permission: server_api::ExternalWorkdirPermission::ReadOnly, }, ) .await diff --git a/openapi/server-api.json b/openapi/server-api.json index b2ad8ca1e..78d8960f5 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -2015,24 +2015,26 @@ "display_name": { "type": "string" }, + "permission": { + "$ref": "#/components/schemas/ExternalWorkdirPermission" + }, "provider_instance_id": { "type": "string" }, - "read_only": { - "type": "boolean" - }, "ttl_seconds": { "format": "uint64", "maximum": 9007199254740991, "minimum": 0, - "type": "integer" + "type": [ + "integer", + "null" + ] } }, "required": [ "provider_instance_id", "display_name", - "ttl_seconds", - "read_only" + "permission" ], "type": "object" }, @@ -2043,7 +2045,11 @@ "type": "string" }, "expires_at": { - "type": "string" + "description": "`None` means the grant has no automatic expiry. It remains bounded by\nexplicit revoke, provider disconnect, and provider shutdown cleanup.", + "type": [ + "string", + "null" + ] }, "generation": { "format": "uint64", @@ -2054,8 +2060,8 @@ "grant_id": { "type": "string" }, - "permissions": { - "type": "string" + "permission": { + "$ref": "#/components/schemas/ExternalWorkdirPermission" }, "provider_instance_id": { "type": "string" @@ -2076,13 +2082,19 @@ "working_directory_id", "provider_instance_id", "display_name", - "permissions", - "expires_at", + "permission", "generation", "status" ], "type": "object" }, + "ExternalWorkdirPermission": { + "enum": [ + "read_only", + "read_write" + ], + "type": "string" + }, "ExtractAudit": { "properties": { "entry_range": { @@ -3808,6 +3820,22 @@ ], "type": "object" }, + "MergeRequestReviewSubject": { + "additionalProperties": false, + "properties": { + "merge_request_id": { + "type": "string" + }, + "subject_ref": { + "type": "string" + } + }, + "required": [ + "merge_request_id", + "subject_ref" + ], + "type": "object" + }, "MergeRequestState": { "enum": [ "open", @@ -3845,6 +3873,15 @@ }, "subject_ref": { "type": "string" + }, + "ticket_item_revision": { + "type": "string" + }, + "ticket_merge_request_subjects": { + "items": { + "$ref": "#/components/schemas/MergeRequestReviewSubject" + }, + "type": "array" } }, "required": [ @@ -3852,6 +3889,8 @@ "event_id", "sequence", "subject_ref", + "ticket_item_revision", + "ticket_merge_request_subjects", "requested_by", "reviewer", "created_at" @@ -3897,6 +3936,15 @@ }, "subject_ref": { "type": "string" + }, + "ticket_item_revision": { + "type": "string" + }, + "ticket_merge_request_subjects": { + "items": { + "$ref": "#/components/schemas/MergeRequestReviewSubject" + }, + "type": "array" } }, "required": [ @@ -3905,6 +3953,8 @@ "sequence", "request_event_id", "subject_ref", + "ticket_item_revision", + "ticket_merge_request_subjects", "decision", "body", "findings", @@ -6303,6 +6353,15 @@ }, "subject_ref": { "type": "string" + }, + "ticket_item_revision": { + "type": "string" + }, + "ticket_merge_request_subjects": { + "items": { + "$ref": "#/components/schemas/MergeRequestReviewSubject" + }, + "type": "array" } }, "required": [ @@ -6310,6 +6369,8 @@ "sequence", "request_event_id", "subject_ref", + "ticket_item_revision", + "ticket_merge_request_subjects", "decision", "body", "findings", @@ -8955,6 +9016,12 @@ } ] }, + "merge_requests": { + "items": { + "$ref": "#/components/schemas/TicketMergeRequestSummary" + }, + "type": "array" + }, "priority": { "type": "string" }, @@ -9038,6 +9105,7 @@ "assignments", "assignment_diagnostics", "action_eligibility", + "merge_requests", "evidence", "record_source" ], @@ -10105,6 +10173,12 @@ } ] }, + "merge_requests": { + "items": { + "$ref": "#/components/schemas/TicketMergeRequestSummary" + }, + "type": "array" + }, "priority": { "type": "string" }, @@ -10172,7 +10246,8 @@ "blocker_count", "unresolved_blocker_count", "unresolved_review_count", - "evidence" + "evidence", + "merge_requests" ], "type": "object" }, @@ -13919,7 +13994,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:a92b7346166ec2316878ef38b0426090cb4bdd0d5b96cae3014bef35c60df02a" + "x-yoi-source-digest": "sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a" }, "openapi": "3.1.0", "paths": { @@ -19132,6 +19207,334 @@ ] } }, + "/api/w/{workspace_id}/merge-requests/{id}/readiness": { + "get": { + "operationId": "merge_request_readiness", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeRequestReadinessResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/merge-requests/{id}/repair-source": { + "post": { + "operationId": "merge_request_selector_repair", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepairMergeRequestSelectorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicMergeRequest" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/merge-requests/{id}/thread": { + "get": { + "operationId": "merge_request_thread", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/MergeRequestThreadQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_MergeRequestThreadEvent" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/merge-requests/{merge_request_id}": { "get": { "operationId": "merge_request_show", @@ -29542,334 +29945,6 @@ ] } }, - "/api/w/{workspace_id}/tickets/{id}/merge-request/readiness": { - "get": { - "operationId": "merge_request_readiness", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MergeRequestReadinessResponse" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source": { - "post": { - "operationId": "merge_request_selector_repair", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepairMergeRequestSelectorRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicMergeRequest" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/merge-request/thread": { - "get": { - "operationId": "merge_request_thread", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": true, - "in": "query", - "name": "query", - "required": true, - "schema": { - "$ref": "#/components/schemas/MergeRequestThreadQuery" - }, - "style": "form" - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Array_of_MergeRequestThreadEvent" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, "/api/w/{workspace_id}/tickets/{id}/orchestration-plans": { "post": { "operationId": "ticket_orchestration_plan_record", diff --git a/resources/flows/coder-review.dcdl b/resources/flows/coder-review.dcdl index 4de100776..de1e7ef6b 100644 --- a/resources/flows/coder-review.dcdl +++ b/resources/flows/coder-review.dcdl @@ -15,7 +15,7 @@ }; review = { - instructions = "Use the current Ticket Merge Request as review authority. Call `ShowMergeRequest` and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope plus an explicit command grant for Workdir inspection and command validation, and only the Ticket id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition."; + instructions = "Use the explicitly addressed current Merge Request as review authority. Call `ShowMergeRequest` with its ID and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope plus an explicit command grant for Workdir inspection and command validation, and both the Ticket id and selected Merge Request id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and current Ticket item revision and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition."; transitions = { approved = { target = "complete"; diff --git a/resources/prompts/common/merge-request.md b/resources/prompts/common/merge-request.md index 136240f7f..fe01c9ac4 100644 --- a/resources/prompts/common/merge-request.md +++ b/resources/prompts/common/merge-request.md @@ -1,9 +1,9 @@ ## Merge Request workflow -The Merge Request and its append-only thread are the routine authority for review requests, verdicts, fixes, rereview, readiness, and completion evidence. Use the operation-specific tools exposed to your role: `OpenMergeRequest`, `ShowMergeRequest`, `ReviewMergeRequest`, `CheckMergeRequestReadiness`, and `CompleteMergeRequest`. +The Merge Request and its append-only thread are the routine authority for review requests, verdicts, fixes, rereview, readiness, and integration evidence. One Ticket may link multiple repository-scoped Merge Requests. After `OpenMergeRequest` returns an ID, every read, review, readiness, and integration operation must explicitly address that `merge_request_id`; never let a Ticket lookup implicitly choose one. Use the operation-specific tools exposed to your role: `OpenMergeRequest`, `ShowMergeRequest`, `ReviewMergeRequest`, `CheckMergeRequestReadiness`, `CompleteMergeRequest`, and `CompleteTicket`. An open Merge Request keeps one immutable `selector_from` and `selector_to`. Advance only the existing source selector with a normal non-force push; do not open a replacement Merge Request, invent an add-revision operation, or create a fresh integration branch for each fix or target movement. Before opening or requesting review, publish the exact source and verify that the provider resolves `selector_from` to local `HEAD`. -A review verdict is valid only for the exact provider-resolved source ref captured by `ReviewRequested`. Moving the source ref requires a fresh review of the new exact source. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. +A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket item revision captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. `CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. After every linked required result is integrated, `CompleteTicket` revalidates the exact linked-MR set, current Ticket revision, explicitly identified Reviewer requirement approval, state, and assignment before completing the Ticket. Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a revision-based workflow. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or completion, stop and reread current authority rather than reusing stale evidence. diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index a184160d6..2d78b1f9c 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -1,9 +1,9 @@ You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request. -Use the existing Merge Request as the routine authority for review requests, verdicts, fixes, and rereview cycles. `OpenMergeRequest` creates the one selector-based Merge Request; if one is already open, use `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority. +Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority. Do not add a Ticket comment for each review or fix iteration. Add a Ticket comment only when a blocker or decision requires Orchestrator attention, or once after approval to hand off the final implementation and validation evidence. -Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. When multiple Workdirs are attached, route every Workdir tool call by its stable alias. Only the Ticket's sole `read_write` target is an implementation/publish/Merge Request repository; `read_only` targets are reference material and must never be modified or used as a command target. Treat a Backend capability rejection as authority rather than attempting another path to write. +Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. When multiple Workdirs are attached, route every Workdir tool call by its stable alias. Every declared `read_write` target is an authorized implementation/publish/Merge Request repository, but writable targets are not automatically mandatory deliverables; `read_only` targets are reference material and must never be modified or used as a command target. Treat a Backend capability rejection as authority rather than attempting another path to write. Before opening a Merge Request, publish only the committed Ticket work branch with a normal non-force push and verify that the Ticket repository remote resolves it to the exact local `HEAD`; a local branch name or dirty Workdir is not immutable review evidence. Do not push the target branch, tags, or unrelated refs, and never force-push. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index 10d8ba079..bbee8320b 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -2,21 +2,21 @@ You are the Ticket Orchestrator role. {% include "common.git" %} -Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable semantic aliases such as `checkout` for the sole `read_write` target and repository-derived aliases for `read_only` targets. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication from the sole `read_write` target's Ticket work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. +Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable repository-derived aliases and grant Backend-derived write capability to every `read_write` target. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication from any required `read_write` target work branch, guarded integration of the approved linked Merge Requests, and guarded Ticket completion to the Workspace Orchestrator by default. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker. -Treat the current linked Merge Request as implementation-completion authority. A current provider-resolved source ref, commit/repository evidence, an effective approval for that exact subject, review freshness after the latest substantive Ticket item edit, and no unresolved request-changes are sufficient; do not require an `implementation_report`. Human summaries remain optional audit context. Recheck `ShowTicket`, `ShowMergeRequest`, and `CheckMergeRequestReadiness` immediately before guarded integration. Require the Merge Request source selector to remain on the exact reviewed commit: source movement requires a fresh Reviewer attempt. Target-only movement preserves source approval and requires refreshed integration evidence against the current target; it does not require a replacement Merge Request, an add-revision operation, or a fresh integration branch. +Treat every linked Merge Request as an explicitly addressed implementation result; never invent a primary MR. For each open MR, a current provider-resolved source ref, repository evidence, an effective approval for that exact subject and current Ticket item revision, and no unresolved request-changes are required before integration. Do not require an `implementation_report`. Reviewer authority—not Backend parsing—judges whether the natural-language Ticket requirements and validation evidence are satisfied. Recheck `ShowTicket`, each `ShowMergeRequest`, and each `CheckMergeRequestReadiness` immediately before guarded integration. Source movement requires a fresh Reviewer attempt. Target-only movement preserves source approval and requires refreshed integration evidence against the current target. -Before integration, run `CheckMergeRequestReadiness` and reread the Ticket, current assignment, and exact approved subject. Treat the provider-resolved `selector_from` as the `merge_from` branch and `selector_to` as the `merge_to` branch. Obtain their exact approved source hash and `target_ref_before`, selected merge strategy, and approval event from authoritative Merge Request evidence. +Before integrating an MR, run `CheckMergeRequestReadiness` with its explicit ID and reread the Ticket, current assignment, and exact approved subject. Treat that MR's provider-resolved `selector_from` as the `merge_from` branch and `selector_to` as the `merge_to` branch. Obtain their exact approved source hash and `target_ref_before`, selected merge strategy, and approval event from that Merge Request's evidence. Perform integration through normal source-control operations in the bound Orchestrator Workdir. Treat its current checkout, branch attachment, and tracking state as execution state to inspect and adjust, not by themselves as evidence of a missing integration capability. Ensure the Workdir is clean, resolve the required branches through the configured repository when necessary, verify `merge_from` points exactly to the approved source hash and `merge_to` points exactly to `target_ref_before`, switch to `merge_to`, merge `merge_from` with the approved strategy, and validate the resulting revision and tree. Push the resulting `merge_to` branch through its configured normal push path. Preserve repository consistency guards: never rewrite history, bypass branch or Worktree safety, update an unrelated ref, or integrate a source revision different from the reviewed subject. Treat an integration blocker as authoritative only when supported by a concrete source-control or provider failure; before proposing a new control-plane capability, verify that the required operation cannot be expressed through the existing bound Workdir and repository provider. -After the push, verify the repository provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with the before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies an already-applied repository integration; it does not update the branch itself. +After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: keep the Ticket and assignment in progress while remaining linked MRs are implemented, reviewed, or integrated. -If the repository push succeeds but completion recording fails, do not push again or invent a new result. Retry the same completion operation and evidence: while no completion event exists, the Server requires the target to remain at the exact `target_ref_after` before it records `MergeResult`, moves the Ticket to `done`, and closes the current assignment atomically. Once that exact operation is recorded, later target movement does not invalidate an idempotent replay of the recorded result. Before recording, any other observed target is a stale/conflicting completion and must fail closed. +If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR/source-result set, identify the effective Reviewer approval that attests that exact snapshot and judges the requirements satisfied, then call `CompleteTicket` with the item revision, MR IDs, and that requirement approval event ID. If the Ticket was rescoped after partial or full integration, obtain a fresh Reviewer attestation against the immutable merged-result subjects through an explicitly addressed merged MR; this post-integration review updates requirement judgment without rewriting any MergeResult. `CompleteTicket` alone moves the Ticket to `done` and releases the assignment atomically; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. @@ -24,6 +24,6 @@ Workspace roots, cwd, profile selector, and launch-prompt configuration are cont Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization. -Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After `CompleteMergeRequest` and Ticket completion, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no current Ticket assignment, then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied. +Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After all `CompleteMergeRequest` operations and guarded `CompleteTicket`, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no current Ticket assignment, then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied. For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; unassign it through the available orchestration authority; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `CurrentAssignment` means unassign and reread before retrying `WorkerRemove`. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, unassign, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, assignment, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions. diff --git a/resources/prompts/role/reviewer.md b/resources/prompts/role/reviewer.md index 97e0014e1..b1a25d6b7 100644 --- a/resources/prompts/role/reviewer.md +++ b/resources/prompts/role/reviewer.md @@ -1,7 +1,7 @@ You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder. -Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation. +Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured explicit Merge Request, `ReviewRequested.subject_ref`, `ticket_item_revision`, and exact linked Merge Request subject snapshot against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. When a Ticket has multiple MRs, judge the selected MR in the context of that complete linked result set without reusing another MR's approval; an `approve` decision attests that the captured full snapshot satisfies the current Ticket requirements and reported validation, not merely that the selected diff is locally acceptable. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation. -Your prose response is not review authority. Before finishing, call `ReviewMergeRequest` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. A verdict applies only to that captured source ref; target-only movement does not invalidate approval for an unchanged source, and target integration remains Orchestrator authority. +Your prose response is not review authority. Before finishing, call `ReviewMergeRequest` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. For an open MR, the Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. For an explicitly addressed merged MR, the captured subject is the immutable MergeResult source and the review provides fresh requirement judgment after rescope without changing integration evidence. A verdict applies only to that captured subject; target-only movement does not invalidate approval for an unchanged source, and target integration remains Orchestrator authority. Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility. diff --git a/web/workspace/deno.json b/web/workspace/deno.json index a7e59c3c8..05284f684 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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 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/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.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/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.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", diff --git a/web/workspace/src/lib/generated/README.md b/web/workspace/src/lib/generated/README.md index 18d58e70d..65613dbe7 100644 --- a/web/workspace/src/lib/generated/README.md +++ b/web/workspace/src/lib/generated/README.md @@ -15,6 +15,21 @@ cargo run -q -p server-api --example generate_repository_openapi_types -- --chec The generator fails closed on unsupported or lossy OpenAPI constructs, including ambiguous nullable unions, external references, semantic `$ref` siblings, unimplemented ordinary schema keywords, unsupported enum literals/formats, and integer ranges that cannot be represented safely by JavaScript numbers. Its one explicit format mapping is canonical `uint64` to a nonnegative JavaScript safe `number`; the runtime parser enforces the same range. The legacy TypeScript generator imports the two shared Repository source projection types it still needs and no longer declares the migrated Repository list/detail types. +## Workspace subscription / Worker protocol + +`protocol.ts` and `protocol-validator.ts` are generated together from the Serde/`schemars` closure of `protocol::subscription::SubscriptionFrame`. Rust DTOs and validation constants in `crates/protocol` remain the sole wire authority. The generator normalizes Serde defaults to the actual serialized required/nullable shape, tightens every supported Rust integer format to its JavaScript-safe wire range, and fails on an unknown integer format. The validator closes every generated object shape, rejects unknown tagged variants and unsafe integers, applies Rust-owned aggregate bounds to object keys as well as values, and mirrors the Browser-relevant `SubscriptionFrame::validate()` selector/payload checks. Rust-serialized compatibility fixtures are embedded for Browser regression tests. The Workspace multiplexer additionally checks live event payloads against its locally retained selector. + +Inbound Browser frames only are validated: Browser-to-Server methods remain compile-time typed and are decoded plus semantically validated by the Rust server. Invalid inbound frames close the shared socket with a fixed, non-reflective diagnostic; the existing reconnect path clears routing state and requests fresh snapshots. A valid `subscription_closed` frame continues to isolate and resubscribe only that subscription. + +Regenerate or verify both artifacts from the repository root: + +```sh +cargo run -q -p protocol --example generate_typescript --features typescript +cargo run -q -p protocol --example generate_typescript --features typescript -- --check +``` + +The `protocol` crate tests also compare both checked-in files byte-for-byte with fresh generator output. + ## Other generated contracts The remaining files retain their generator command in their header. `legacy-server-api.ts` is transitional: new bounded domain slices should use a focused contract artifact instead of adding another API surface to it. diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index e6cfb44d9..def8cb001 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -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:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) +// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a) // 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 diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts new file mode 100644 index 000000000..4f91c778e --- /dev/null +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -0,0 +1,435 @@ +// @generated by `cargo run -p protocol --example generate_typescript --features typescript` +// Do not edit by hand. Rust DTO and validation authority lives in `crates/protocol`. + +import type { + EventSubscriptionSelector, + SubscriptionEventPayload, + SubscriptionFrame, + SubscriptionSnapshot, + SubscriptionWorker, + WorkspaceSubscriptionWorkdir, +} from './protocol'; + +export const SUBSCRIPTION_PROTOCOL_VERSION = 1; +export const MAX_SUBSCRIPTION_FRAME_JSON_BYTES = 16777216; +export const MAX_SUBSCRIPTION_STRING_BYTES = 4194304; +export const MAX_SUBSCRIPTION_COLLECTION_ITEMS = 100000; +export const MAX_SUBSCRIPTION_VALUE_DEPTH = 64; +export const MAX_SUBSCRIPTION_VALUE_NODES = 250000; + +const MAX_CORRELATION_ID_BYTES = 128; +const MAX_RESOURCE_ID_BYTES = 256; +const MAX_WORKER_IDS_PER_SELECTOR = 256; +const MAX_REJECTION_MESSAGE_BYTES = 1024; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. New submissions are accepted only while Idle,\nand a `Started` receipt follows the pending activation checkpoint; the\ncorresponding [`Event::UserMessage`] marks the later history commit.\n`Queued` remains readable for receipts persisted by older runtimes.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by the TUI when a session starts empty.\n\nBuilt once in the Worker controller from the resolved manifest and\ntransmitted alongside `Event::Snapshot` so clients don't need\ntheir own view of the manifest.","properties":{"context_tokens":{"default":0,"description":"Estimated current session context tokens at connect time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_window":{"default":0,"description":"Model context window in tokens. Always filled by the Worker greeting.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation while the Worker is Idle.\nRunning or Paused Workers reject Submit; use Notify for advisory information\nthat should be incorporated into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Payload segments and\nprovenance remain in the session log and are intentionally not exposed.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Compatibility value for durable receipts created before Submit became\nIdle-only. New submissions are never accepted with this disposition.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subject_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","subject_revision","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"snapshot_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot_revision","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"subject_revision":{"description":"Producer-owned monotonic revision for this Worker subject.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest revisioned foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","subject_revision","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = + [{"protocol_version":1,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot_revision":1,"snapshot":{"topic":"workers","data":{"workers":[{"worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","subject_revision":1,"state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace"}]}}}}},{"protocol_version":1,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","subject_revision":2,"payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}}]; +const utf8 = new TextEncoder(); + +type JsonObject = Record; +type RejectionReason = + | 'invalid_transport' + | 'frame_too_large' + | 'invalid_json' + | 'aggregate_limit' + | 'invalid_structure' + | 'invalid_semantics'; + +export type SubscriptionFrameDecodeResult = + | { ok: true; value: SubscriptionFrame } + | { ok: false; reason: RejectionReason }; + +/** + * Decode an untrusted Workspace WebSocket message without reflecting its content. + * A successful result has passed aggregate limits, the exact generated JSON schema, + * and the Rust protocol's Browser-relevant semantic checks. + */ +export function decodeSubscriptionFrame(input: unknown): SubscriptionFrameDecodeResult { + if (typeof input !== 'string') return { ok: false, reason: 'invalid_transport' }; + if (utf8.encode(input).byteLength > MAX_SUBSCRIPTION_FRAME_JSON_BYTES) { + return { ok: false, reason: 'frame_too_large' }; + } + + let candidate: unknown; + try { + candidate = JSON.parse(input) as unknown; + } catch { + return { ok: false, reason: 'invalid_json' }; + } + if (!withinAggregateLimits(candidate)) return { ok: false, reason: 'aggregate_limit' }; + if (!validateSchema(candidate, subscriptionFrameSchema)) { + return { ok: false, reason: 'invalid_structure' }; + } + + // The assertion follows generated structural validation; it is not a parse-time cast. + const frame = candidate as SubscriptionFrame; + if (!validateInboundFrameSemantics(frame)) { + return { ok: false, reason: 'invalid_semantics' }; + } + return { ok: true, value: frame }; +} + +/** Validate stateful selector/payload consistency after the multiplexer resolves routing. */ +export function subscriptionFrameMatchesSelector( + frame: SubscriptionFrame, + selector: EventSubscriptionSelector, +): boolean { + if (frame.frame === 'response' && frame.message.result === 'subscribed') { + return selectorsEqual(frame.message.payload.selector, selector); + } + if (frame.frame === 'event' && frame.message.event === 'event') { + return eventPayloadMatchesSelector(frame.message.data.payload, selector); + } + return true; +} + +function withinAggregateLimits(root: unknown): boolean { + let nodes = 0; + const visit = (value: unknown, depth: number): boolean => { + nodes += 1; + if (nodes > MAX_SUBSCRIPTION_VALUE_NODES || depth > MAX_SUBSCRIPTION_VALUE_DEPTH) return false; + if (typeof value === 'string') { + return utf8.encode(value).byteLength <= MAX_SUBSCRIPTION_STRING_BYTES; + } + if (typeof value === 'number') { + return Number.isFinite(value) && (!Number.isInteger(value) || Number.isSafeInteger(value)); + } + if (value === null || typeof value === 'boolean') return true; + if (Array.isArray(value)) { + return ( + value.length <= MAX_SUBSCRIPTION_COLLECTION_ITEMS && + value.every((entry) => visit(entry, depth + 1)) + ); + } + if (!isObject(value)) return false; + const entries = Object.entries(value); + return ( + entries.length <= MAX_SUBSCRIPTION_COLLECTION_ITEMS && + entries.every( + ([key, entry]) => visit(key, depth + 1) && visit(entry, depth + 1), + ) + ); + }; + return visit(root, 0); +} + +function validateSchema(value: unknown, rawSchema: unknown): boolean { + if (rawSchema === true) return true; + if (rawSchema === false || !isObject(rawSchema)) return false; + const schema = rawSchema; + + if (typeof schema.$ref === 'string') { + const target = resolveLocalReference(schema.$ref); + if (target === undefined || !validateSchema(value, target)) return false; + } + if ('const' in schema && !jsonEqual(value, schema.const)) return false; + if (Array.isArray(schema.enum) && !schema.enum.some((entry) => jsonEqual(value, entry))) { + return false; + } + if (Array.isArray(schema.oneOf)) { + let matches = 0; + for (const alternative of schema.oneOf) { + if (validateSchema(value, alternative)) matches += 1; + } + if (matches !== 1) return false; + } + if (Array.isArray(schema.anyOf) && !schema.anyOf.some((entry) => validateSchema(value, entry))) { + return false; + } + + if (schema.type !== undefined && !matchesType(value, schema.type)) return false; + if (typeof value === 'number') { + if (schema.type === 'integer' && !Number.isSafeInteger(value)) return false; + if (typeof schema.minimum === 'number' && value < schema.minimum) return false; + if (typeof schema.maximum === 'number' && value > schema.maximum) return false; + } + if (typeof value === 'string') { + const length = [...value].length; + if (typeof schema.minLength === 'number' && length < schema.minLength) return false; + if (typeof schema.maxLength === 'number' && length > schema.maxLength) return false; + } + if (Array.isArray(value)) { + if (typeof schema.minItems === 'number' && value.length < schema.minItems) return false; + if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false; + if (schema.items !== undefined && !value.every((entry) => validateSchema(entry, schema.items))) { + return false; + } + } + if (isObject(value) && schema.properties !== undefined) { + if (!isObject(schema.properties)) return false; + const properties = schema.properties; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + if (typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(value, key)) return false; + } + } + for (const [key, entry] of Object.entries(value)) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + if (!validateSchema(entry, properties[key])) return false; + } else if (schema.additionalProperties === false) { + return false; + } else if (isObject(schema.additionalProperties) || typeof schema.additionalProperties === 'boolean') { + if (!validateSchema(entry, schema.additionalProperties)) return false; + } + } + } + return true; +} + +function resolveLocalReference(reference: string): unknown { + if (!reference.startsWith('#/')) return undefined; + let current: unknown = subscriptionFrameSchema; + for (const encoded of reference.slice(2).split('/')) { + if (!isObject(current)) return undefined; + const key = encoded.replaceAll('~1', '/').replaceAll('~0', '~'); + current = current[key]; + } + return current; +} + +function matchesType(value: unknown, rawType: unknown): boolean { + if (Array.isArray(rawType)) return rawType.some((entry) => matchesType(value, entry)); + switch (rawType) { + case 'null': + return value === null; + case 'boolean': + return typeof value === 'boolean'; + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + return typeof value === 'number' && Number.isSafeInteger(value); + case 'array': + return Array.isArray(value); + case 'object': + return isObject(value); + default: + return false; + } +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function jsonEqual(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && left.every((entry, index) => jsonEqual(entry, right[index])); + } + if (isObject(left) && isObject(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => Object.prototype.hasOwnProperty.call(right, key) && jsonEqual(left[key], right[key]), + ) + ); + } + return false; +} + +function validateInboundFrameSemantics(frame: SubscriptionFrame): boolean { + if (frame.protocol_version !== SUBSCRIPTION_PROTOCOL_VERSION) return false; + if (frame.frame === 'request' || frame.frame === 'worker_protocol') return false; + if (frame.frame === 'response') { + const message = frame.message; + if (!validateIdentifier(message.payload.request_id, MAX_CORRELATION_ID_BYTES)) return false; + if (message.result === 'subscribed') { + return ( + validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES) && + validateSelector(message.payload.selector) && + snapshotMatchesSelector(message.payload.snapshot, message.payload.selector) + ); + } + if (message.result === 'unsubscribed') { + return validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES); + } + return ( + (message.payload.subscription_id == null || + validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES)) && + validateBoundedMessage(message.payload.message) + ); + } + + const message = frame.message; + if (!validateIdentifier(message.data.subscription_id, MAX_CORRELATION_ID_BYTES)) return false; + if (message.event === 'subscription_closed') { + return validateBoundedMessage(message.data.message); + } + return validateEventPayload(message.data.payload); +} + +function validateSelector(selector: EventSubscriptionSelector): boolean { + if (selector.topic === 'worker_lifecycle') { + if ( + selector.worker_ids.length === 0 || + selector.worker_ids.length > MAX_WORKER_IDS_PER_SELECTOR + ) { + return false; + } + const ids = new Set(); + for (const workerId of selector.worker_ids) { + if (!validateIdentifier(workerId, MAX_RESOURCE_ID_BYTES) || ids.has(workerId)) return false; + ids.add(workerId); + } + return true; + } + if (selector.topic === 'worker_protocol') { + return ( + validateIdentifier(selector.worker_id, MAX_RESOURCE_ID_BYTES) && + (selector.runtime_id == null || + validateIdentifier(selector.runtime_id, MAX_RESOURCE_ID_BYTES)) + ); + } + return true; +} + +function snapshotMatchesSelector( + snapshot: SubscriptionSnapshot, + selector: EventSubscriptionSelector, +): boolean { + if (!validateSelector(selector)) return false; + if (selector.topic === 'runtime_workers' || selector.topic === 'workspace_workers') { + return snapshot.topic === 'workers' && validateWorkers(snapshot.data.workers); + } + if (selector.topic === 'worker_lifecycle') { + const selected = new Set(selector.worker_ids); + return ( + snapshot.topic === 'workers' && + validateWorkers(snapshot.data.workers) && + snapshot.data.workers.every((worker) => selected.has(worker.worker_id)) + ); + } + if (selector.topic === 'worker_protocol') { + return snapshot.topic === 'worker_protocol' && snapshot.data.worker_id === selector.worker_id; + } + return ( + snapshot.topic === 'workspace_workdirs' && + snapshot.data.workdirs.every(validateWorkspaceWorkdir) + ); +} + +function validateEventPayload(payload: SubscriptionEventPayload): boolean { + switch (payload.event) { + case 'worker_upserted': + return validateWorker(payload.data.worker); + case 'worker_removed': + return ( + validateIdentifier(payload.data.worker_id, MAX_RESOURCE_ID_BYTES) && + (payload.data.runtime_id == null || + validateIdentifier(payload.data.runtime_id, MAX_RESOURCE_ID_BYTES)) + ); + case 'worker_protocol': + return validateIdentifier(payload.data.worker_id, MAX_RESOURCE_ID_BYTES); + case 'workdir_upserted': + return validateWorkspaceWorkdir(payload.data.workdir); + case 'workdir_removed': + return validateIdentifier(payload.data.working_directory_id, MAX_RESOURCE_ID_BYTES); + } +} + +function eventPayloadMatchesSelector( + payload: SubscriptionEventPayload, + selector: EventSubscriptionSelector, +): boolean { + if (selector.topic === 'runtime_workers' || selector.topic === 'workspace_workers') { + return payload.event === 'worker_upserted' || payload.event === 'worker_removed'; + } + if (selector.topic === 'worker_lifecycle') { + return ( + (payload.event === 'worker_upserted' && selector.worker_ids.includes(payload.data.worker.worker_id)) || + (payload.event === 'worker_removed' && selector.worker_ids.includes(payload.data.worker_id)) + ); + } + if (selector.topic === 'worker_protocol') { + return payload.event === 'worker_protocol' && payload.data.worker_id === selector.worker_id; + } + return payload.event === 'workdir_upserted' || payload.event === 'workdir_removed'; +} + +function selectorsEqual( + left: EventSubscriptionSelector, + right: EventSubscriptionSelector, +): boolean { + if (left.topic !== right.topic) return false; + if (left.topic === 'worker_lifecycle' && right.topic === 'worker_lifecycle') { + const rightIds = new Set(right.worker_ids); + return left.worker_ids.length === rightIds.size && left.worker_ids.every((workerId) => rightIds.has(workerId)); + } + if (left.topic === 'worker_protocol' && right.topic === 'worker_protocol') { + return ( + left.worker_id === right.worker_id && + (left.runtime_id ?? null) === (right.runtime_id ?? null) + ); + } + return true; +} + +function validateWorkers(workers: SubscriptionWorker[]): boolean { + const identities = new Set(); + for (const worker of workers) { + const identity = `${worker.runtime_id ?? ''}\u0000${worker.worker_id}`; + if (!validateWorker(worker) || identities.has(identity)) return false; + identities.add(identity); + } + return true; +} + +function validateWorker(worker: SubscriptionWorker): boolean { + if (!validateIdentifier(worker.worker_id, MAX_RESOURCE_ID_BYTES)) return false; + if (worker.runtime_id != null && !validateIdentifier(worker.runtime_id, MAX_RESOURCE_ID_BYTES)) { + return false; + } + if (worker.resource_key != null && !validateIdentifier(worker.resource_key, MAX_RESOURCE_ID_BYTES)) { + return false; + } + const aliases = new Set(); + const workdirIds = new Set(); + for (const attachment of worker.workdir_attachments ?? []) { + if ( + !validateIdentifier(attachment.alias, MAX_RESOURCE_ID_BYTES) || + !validateIdentifier(attachment.working_directory_id, MAX_RESOURCE_ID_BYTES) || + attachment.repository_key == null || + !validateRepositoryKey(attachment.repository_key) || + aliases.has(attachment.alias) || + workdirIds.has(attachment.working_directory_id) + ) { + return false; + } + aliases.add(attachment.alias); + workdirIds.add(attachment.working_directory_id); + } + return true; +} + +function validateWorkspaceWorkdir(workdir: WorkspaceSubscriptionWorkdir): boolean { + return ( + validateIdentifier(workdir.working_directory_id, MAX_RESOURCE_ID_BYTES) && + validateRepositoryKey(workdir.repository_key) && + validateIdentifier(workdir.state, MAX_RESOURCE_ID_BYTES) + ); +} + +function validateIdentifier(value: string, maxBytes: number): boolean { + return ( + value.length > 0 && + utf8.encode(value).byteLength <= maxBytes && + value.trim() === value && + !/\p{Cc}/u.test(value) + ); +} + +function validateRepositoryKey(value: string): boolean { + return ( + utf8.encode(value).byteLength <= 64 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) + ); +} + +function validateBoundedMessage(value: string): boolean { + const bytes = utf8.encode(value).byteLength; + return bytes > 0 && bytes <= MAX_REJECTION_MESSAGE_BYTES; +} diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 72ec21104..f7baeefdf 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -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:a92b7346166ec2316878ef38b0426090cb4bdd0d5b96cae3014bef35c60df02a) +// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a) // 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 diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 791ad8d01..fe0ab354c 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -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:a92b7346166ec2316878ef38b0426090cb4bdd0d5b96cae3014bef35c60df02a) +// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a) // 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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 7f9514be6..b45fe6e0a 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -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:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) +// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a) // 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 @@ -47,9 +47,11 @@ export type MergeRequestRefDiagnostic = { code: string; message: string; }; export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; }; +export type MergeRequestReviewSubject = { merge_request_id: string; subject_ref: string; }; + export type MergeRequestState = "open" | "merged" | "closed"; -export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; }; +export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; ticket_merge_request_subjects: Array; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; ticket_merge_request_subjects: Array; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; }; export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; }; @@ -89,7 +91,7 @@ export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contrib export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; item_revision: string; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; targets: Array; title: string; updated_at?: string | null; }; +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; item_revision: string; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; targets: Array; title: string; updated_at?: string | null; }; export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index e0874bdac..feaf762c5 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) +// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a) // 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 @@ -9,9 +9,11 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe export type DiagnosticSeverity = "info" | "warning" | "error"; -export type ExternalWorkdirGrantCreateRequest = { display_name: string; provider_instance_id: string; read_only: boolean; ttl_seconds: number; }; +export type ExternalWorkdirGrantCreateRequest = { display_name: string; permission: ExternalWorkdirPermission; provider_instance_id: string; ttl_seconds?: number | null; }; -export type ExternalWorkdirGrantResponse = { display_name: string; expires_at: string; generation: number; grant_id: string; permissions: string; provider_instance_id: string; status: string; working_directory_id: string; workspace_id: string; }; +export type ExternalWorkdirGrantResponse = { display_name: string; expires_at?: string | null; generation: number; grant_id: string; permission: ExternalWorkdirPermission; provider_instance_id: string; status: string; working_directory_id: string; workspace_id: string; }; + +export type ExternalWorkdirPermission = "read_only" | "read_write"; export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 08442458b..59423f838 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -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:a92b7346166ec2316878ef38b0426090cb4bdd0d5b96cae3014bef35c60df02a) +// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a) // 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 diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index 4e0b38ac8..6b846ad5d 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -6,6 +6,7 @@ import type { MergeRequestListResponse, MergeRequestRefDiagnostic, MergeRequestRefResponse, + MergeRequestReviewSubject, MergeRequestThreadEvent, MergeRequestWorkerIdentity, ObjectiveDetail, @@ -712,6 +713,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { "item_revision", "linked_objectives", "merge_request", + "merge_requests", "priority", "queued_at", "queued_by", @@ -769,6 +771,11 @@ export function parseTicketDetail(value: unknown): TicketDetail { `${label}.linked_objectives`, parseObjectiveLink, ), + merge_requests: array( + item.merge_requests, + `${label}.merge_requests`, + parseTicketMergeRequestSummary, + ), merge_request: item.merge_request === undefined ? undefined : item.merge_request === null @@ -1049,6 +1056,21 @@ function parseMergeRequestWorker( }; } +function parseMergeRequestReviewSubject( + value: unknown, + label: string, +): MergeRequestReviewSubject { + const item = object(value, label); + exact(item, ["merge_request_id", "subject_ref"], label); + return { + merge_request_id: string( + item.merge_request_id, + `${label}.merge_request_id`, + ), + subject_ref: string(item.subject_ref, `${label}.subject_ref`), + }; +} + function parseReviewFinding(value: unknown, label: string): ReviewFinding { const item = object(value, label); exact(item, ["body", "code", "line", "path", "severity"], label); @@ -1082,7 +1104,18 @@ function parseMergeRequestThreadEvent( sequence: integer(item.sequence, `${label}.sequence`), }; if (kind === "review_requested") { - exact(item, [...base, "requested_by", "reviewer", "subject_ref"], label); + exact( + item, + [ + ...base, + "requested_by", + "reviewer", + "subject_ref", + "ticket_item_revision", + "ticket_merge_request_subjects", + ], + label, + ); return { ...eventBase, kind, @@ -1092,6 +1125,15 @@ function parseMergeRequestThreadEvent( ), reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`), subject_ref: string(item.subject_ref, `${label}.subject_ref`), + ticket_item_revision: string( + item.ticket_item_revision, + `${label}.ticket_item_revision`, + ), + ticket_merge_request_subjects: array( + item.ticket_merge_request_subjects, + `${label}.ticket_merge_request_subjects`, + parseMergeRequestReviewSubject, + ), }; } if (kind === "review") { @@ -1103,6 +1145,8 @@ function parseMergeRequestThreadEvent( "request_event_id", "reviewer", "subject_ref", + "ticket_item_revision", + "ticket_merge_request_subjects", ], label); const decision = string(item.decision, `${label}.decision`); if (!["approve", "request_changes"].includes(decision)) { @@ -1120,6 +1164,15 @@ function parseMergeRequestThreadEvent( ), reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`), subject_ref: string(item.subject_ref, `${label}.subject_ref`), + ticket_item_revision: string( + item.ticket_item_revision, + `${label}.ticket_item_revision`, + ), + ticket_merge_request_subjects: array( + item.ticket_merge_request_subjects, + `${label}.ticket_merge_request_subjects`, + parseMergeRequestReviewSubject, + ), }; } if (kind === "review_revoked") { diff --git a/web/workspace/src/lib/workspace/console/ConsoleDisplayState.svelte b/web/workspace/src/lib/workspace/console/ConsoleDisplayState.svelte new file mode 100644 index 000000000..1eb3c9591 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ConsoleDisplayState.svelte @@ -0,0 +1,150 @@ + + +{#if state.kind === "loading"} +
+ + + Loading conversation + {#if state.stage === "session"} + Checking Worker Session availability… + {:else} + Waiting for the initial conversation snapshot… + {/if} + +
+{:else if state.kind === "unavailable" || state.kind === "failed"} + +{:else} + {#if state.kind === "stale"} +
+ + + {state.phase === "reconnecting" + ? "Refreshing conversation" + : "Conversation updates are unavailable"} + + {state.reason} Previously loaded conversation is preserved. + + +
+ {:else if state.source === "retained"} +
+ + Read-only retained conversation + This saved Session snapshot cannot receive live updates. + +
+ {/if} + + {#if state.kind === "ready" && !hasContent} +
+ + No conversation to display + + {hasUnfilteredContent + ? "No console items match the current display mode." + : "This Worker view has no conversation history yet."} + + +
+ {/if} +{/if} + + diff --git a/web/workspace/src/lib/workspace/console/console-display-state.ts b/web/workspace/src/lib/workspace/console/console-display-state.ts new file mode 100644 index 000000000..c20d14836 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/console-display-state.ts @@ -0,0 +1,37 @@ +export type ConsoleDisplaySource = "live" | "retained"; + +export type ConsoleDisplayState = + | { kind: "loading"; stage: "session" | "snapshot" } + | { kind: "ready"; source: ConsoleDisplaySource } + | { kind: "unavailable"; reason: string } + | { kind: "failed"; reason: string } + | { + kind: "stale"; + source: ConsoleDisplaySource; + phase: "reconnecting" | "disconnected"; + reason: string; + }; + +const MAX_CONSOLE_REASON_LENGTH = 240; + +export function boundedConsoleReason( + reason: string | null | undefined, + fallback: string, +): string { + const normalized = reason?.replace(/\s+/g, " ").trim() || fallback; + return normalized.slice(0, MAX_CONSOLE_REASON_LENGTH); +} + +export function displayedConsoleSource( + state: ConsoleDisplayState, +): ConsoleDisplaySource | null { + switch (state.kind) { + case "ready": + case "stale": + return state.source; + case "loading": + case "unavailable": + case "failed": + return null; + } +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 16cf57efa..1200041a7 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -555,6 +555,56 @@ Deno.test("Worker Console uses protocol observation events without transcript fe ); }); +Deno.test("Worker Console models availability separately from projected lines", async () => { + const consolePage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + import.meta.url, + ), + ); + const displayState = await Deno.readTextFile( + new URL("./ConsoleDisplayState.svelte", import.meta.url), + ); + const displayModel = await Deno.readTextFile( + new URL("./console-display-state.ts", import.meta.url), + ); + + assert( + consolePage.includes("consoleDisplayState") && + consolePage.includes("pendingInitialSnapshotApplication") && + consolePage.includes('source: "retained"') && + consolePage.includes('source: "live"') && + consolePage.includes("completeInitialSnapshot(") && + consolePage.includes("showConsoleUnavailable(") && + consolePage.includes("retryConsoleLoad") && + !consolePage.includes( + "{#if lines.length === 0}\n

No console output", + ), + "Console availability must advance on snapshot projection rather than infer loading, failure, or empty history from line count", + ); + assert( + displayModel.includes('kind: "loading"') && + displayModel.includes('kind: "ready"') && + displayModel.includes('kind: "unavailable"') && + displayModel.includes('kind: "failed"') && + displayModel.includes('kind: "stale"') && + displayModel.includes("MAX_CONSOLE_REASON_LENGTH = 240"), + "Console display state must explicitly model loading, ready, unavailable, failed, and stale outcomes with bounded reasons", + ); + assert( + displayState.includes('role="status"') && + displayState.includes('role="alert"') && + displayState.includes('aria-live="polite"') && + displayState.includes("prefers-reduced-motion: no-preference") && + displayState.includes("Read-only retained conversation") && + displayState.includes( + "No console items match the current display mode.", + ) && + displayState.includes(">Retry"), + "Console state presentation must expose accessible loading/errors, retained and filtered-empty copy, retry, and reduced-motion-safe animation", + ); +}); + Deno.test("Worker Console owns its narrower centered shell width", async () => { const page = await Deno.readTextFile( new URL( diff --git a/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts b/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts new file mode 100644 index 000000000..23eaa7a5d --- /dev/null +++ b/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { WorkspaceMultiplexer } from './multiplexer'; + +type SocketEvent = 'open' | 'message' | 'error' | 'close'; +type SocketListener = (event: { data?: unknown }) => void; + +class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: FakeWebSocket[] = []; + + readyState = FakeWebSocket.CONNECTING; + readonly sent: string[] = []; + readonly closes: Array<[number | undefined, string | undefined]> = []; + readonly #listeners = new Map(); + + constructor(readonly url: URL) { + FakeWebSocket.instances.push(this); + } + + addEventListener(kind: SocketEvent, listener: SocketListener): void { + const listeners = this.#listeners.get(kind) ?? []; + listeners.push(listener); + this.#listeners.set(kind, listeners); + } + + send(value: string): void { + this.sent.push(value); + } + + close(code?: number, reason?: string): void { + this.closes.push([code, reason]); + if (this.readyState === FakeWebSocket.CLOSED) return; + this.readyState = FakeWebSocket.CLOSED; + this.#emit('close'); + } + + open(): void { + this.readyState = FakeWebSocket.OPEN; + this.#emit('open'); + } + + message(value: unknown): void { + this.#emit('message', { data: value }); + } + + #emit(kind: SocketEvent, event: { data?: unknown } = {}): void { + for (const listener of this.#listeners.get(kind) ?? []) listener(event); + } +} + +beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +test('invalid inbound frame closes before dispatch and reconnects for a fresh snapshot', () => { + vi.useFakeTimers(); + const multiplexer = new WorkspaceMultiplexer('workspace-validation-test'); + const onFrame = vi.fn(); + const onStatus = vi.fn(); + multiplexer.subscribe({ topic: 'workspace_workers' }, { onFrame, onStatus }); + + const first = FakeWebSocket.instances[0]; + expect(first).toBeDefined(); + first.open(); + expect(first.sent).toHaveLength(1); + + first.message( + JSON.stringify({ + protocol_version: 1, + frame: 'event', + message: { + event: 'subscription_closed', + data: { + subscription_id: 'subscription-1', + code: 'lagged', + message: 'untrusted detail', + newer_field: true, + }, + }, + }), + ); + + expect(onFrame).not.toHaveBeenCalled(); + expect(first.closes).toEqual([[1002, 'Invalid workspace protocol frame']]); + expect(onStatus).toHaveBeenLastCalledWith('closed', 'Workspace protocol frame rejected'); + expect(JSON.stringify(onStatus.mock.calls)).not.toContain('untrusted detail'); + + vi.advanceTimersByTime(500); + const second = FakeWebSocket.instances[1]; + expect(second).toBeDefined(); + second.open(); + expect(second.sent).toHaveLength(1); + expect(JSON.parse(second.sent[0]).message.method).toBe('subscribe_events'); + multiplexer.dispose(); +}); + +test('valid subscription closure remains isolated and resubscribes only its selector', () => { + const multiplexer = new WorkspaceMultiplexer('workspace-isolation-test'); + const workersFrame = vi.fn(); + const workersStatus = vi.fn(); + const workdirsFrame = vi.fn(); + const workdirsStatus = vi.fn(); + multiplexer.subscribe( + { topic: 'workspace_workers' }, + { onFrame: workersFrame, onStatus: workersStatus }, + ); + multiplexer.subscribe( + { topic: 'workspace_workdirs' }, + { onFrame: workdirsFrame, onStatus: workdirsStatus }, + ); + + const socket = FakeWebSocket.instances[0]; + socket.open(); + const requests = socket.sent.map((value) => JSON.parse(value)); + expect(requests).toHaveLength(2); + for (const [index, request] of requests.entries()) { + if (request.frame !== 'request' || request.message.method !== 'subscribe_events') { + throw new Error('expected subscribe request'); + } + const selector = request.message.params.selector; + const snapshot = + selector.topic === 'workspace_workers' + ? { topic: 'workers', data: { workers: [] } } + : { topic: 'workspace_workdirs', data: { workdirs: [] } }; + socket.message( + JSON.stringify({ + protocol_version: 1, + frame: 'response', + message: { + result: 'subscribed', + payload: { + request_id: request.message.params.request_id, + subscription_id: `subscription-${index + 1}`, + selector, + snapshot_revision: 1, + snapshot, + }, + }, + }), + ); + } + + const sentBeforeClosure = socket.sent.length; + socket.message( + JSON.stringify({ + protocol_version: 1, + frame: 'event', + message: { + event: 'subscription_closed', + data: { + subscription_id: 'subscription-1', + code: 'lagged', + message: 'resubscribe for a fresh snapshot', + }, + }, + }), + ); + + expect(workersFrame).toHaveBeenCalledTimes(2); + expect(workersStatus).toHaveBeenLastCalledWith( + 'connecting', + 'resubscribe for a fresh snapshot', + ); + expect(workdirsFrame).toHaveBeenCalledTimes(1); + expect(workdirsStatus).not.toHaveBeenCalledWith( + 'connecting', + 'resubscribe for a fresh snapshot', + ); + expect(socket.sent).toHaveLength(sentBeforeClosure + 1); + expect(JSON.parse(socket.sent.at(-1)!).message.params.selector).toEqual({ + topic: 'workspace_workers', + }); + expect(socket.closes).toEqual([]); + multiplexer.dispose(); +}); diff --git a/web/workspace/src/lib/workspace/multiplexer.ts b/web/workspace/src/lib/workspace/multiplexer.ts index 5436e1617..a1ca52865 100644 --- a/web/workspace/src/lib/workspace/multiplexer.ts +++ b/web/workspace/src/lib/workspace/multiplexer.ts @@ -5,6 +5,10 @@ import type { SubscriptionFrame, SubscriptionId, } from '$lib/generated/protocol'; +import { + decodeSubscriptionFrame, + subscriptionFrameMatchesSelector, +} from '$lib/generated/protocol-validator'; import { workspaceApiPath } from '$lib/workspace/api/http'; type Listener = { @@ -56,6 +60,7 @@ export class WorkspaceMultiplexer { readonly #runtimeSubscriptions = new Map(); #socket: WebSocket | null = null; #reconnectTimer: ReturnType | null = null; + #disconnectMessage: string | null = null; #closed = false; constructor(workspaceId: string) { @@ -106,17 +111,20 @@ export class WorkspaceMultiplexer { this.#sendSubscribe(subscription); } }); - socket.addEventListener('message', (event) => this.#receive(String(event.data))); + socket.addEventListener('message', (event) => this.#receive(event.data)); socket.addEventListener('error', () => socket.close()); socket.addEventListener('close', () => { if (this.#socket !== socket) return; this.#socket = null; + const disconnectMessage = + this.#disconnectMessage ?? 'Workspace subscription disconnected'; + this.#disconnectMessage = null; this.#requests.clear(); this.#runtimeSubscriptions.clear(); for (const subscription of this.#subscriptions.values()) { subscription.requestId = null; subscription.subscriptionId = null; - subscription.listener.onStatus?.('closed', 'Workspace subscription disconnected'); + subscription.listener.onStatus?.('closed', disconnectMessage); } if (!this.#closed && this.#subscriptions.size > 0) { this.#reconnectTimer = setTimeout(() => this.#ensureConnected(), 500); @@ -138,25 +146,23 @@ export class WorkspaceMultiplexer { }); } - #receive(text: string): void { - let frame: SubscriptionFrame; - try { - frame = JSON.parse(text) as SubscriptionFrame; - } catch { - this.#socket?.close(); - return; - } - if (frame.protocol_version !== 1) { - this.#socket?.close(); + #receive(input: unknown): void { + const decoded = decodeSubscriptionFrame(input); + if (!decoded.ok) { + this.#rejectInboundFrame(); return; } + const frame = decoded.value; if (frame.frame === 'response' && frame.message.result === 'subscribed') { const clientId = this.#requests.get(frame.message.payload.request_id); const subscription = clientId ? this.#subscriptions.get(clientId) : undefined; if (!clientId || !subscription) return; + if (!subscriptionFrameMatchesSelector(frame, subscription.selector)) { + this.#rejectInboundFrame(); + return; + } this.#requests.delete(frame.message.payload.request_id); const subscriptionId = frame.message.payload.subscription_id; - if (!subscriptionId) return; subscription.subscriptionId = subscriptionId; this.#runtimeSubscriptions.set(subscriptionId, clientId); subscription.listener.onFrame(frame); @@ -175,6 +181,13 @@ export class WorkspaceMultiplexer { if (frame.frame === 'event') { const clientId = this.#runtimeSubscriptions.get(frame.message.data.subscription_id); const subscription = clientId ? this.#subscriptions.get(clientId) : undefined; + if ( + subscription && + !subscriptionFrameMatchesSelector(frame, subscription.selector) + ) { + this.#rejectInboundFrame(); + return; + } subscription?.listener.onFrame(frame); if ( frame.message.event === 'subscription_closed' && @@ -190,6 +203,11 @@ export class WorkspaceMultiplexer { } } + #rejectInboundFrame(): void { + this.#disconnectMessage = 'Workspace protocol frame rejected'; + this.#socket?.close(1002, 'Invalid workspace protocol frame'); + } + #sendWorkerMethod(clientId: string, method: Method): void { const subscription = this.#subscriptions.get(clientId); if (!subscription?.subscriptionId) throw new Error('Worker protocol subscription is not open'); diff --git a/web/workspace/src/lib/workspace/protocol-validator.test.ts b/web/workspace/src/lib/workspace/protocol-validator.test.ts new file mode 100644 index 000000000..801968503 --- /dev/null +++ b/web/workspace/src/lib/workspace/protocol-validator.test.ts @@ -0,0 +1,269 @@ +import type { EventSubscriptionSelector, SubscriptionFrame } from '$lib/generated/protocol'; +import { + decodeSubscriptionFrame, + MAX_SUBSCRIPTION_STRING_BYTES, + RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES, + subscriptionFrameMatchesSelector, +} from '$lib/generated/protocol-validator'; + +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function subscribedFrame(overrides: Record = {}): Record { + return { + protocol_version: 1, + frame: 'response', + message: { + result: 'subscribed', + payload: { + request_id: 'request-1', + subscription_id: 'subscription-1', + selector: { topic: 'workspace_workers' }, + snapshot_revision: 1, + snapshot: { topic: 'workers', data: { workers: [] } }, + ...overrides, + }, + }, + }; +} + +function decode(value: unknown) { + return decodeSubscriptionFrame(JSON.stringify(value)); +} + +Deno.test('generated subscription validator accepts a current exact frame', () => { + const result = decode(subscribedFrame()); + assert(result.ok, `expected valid frame, got ${JSON.stringify(result)}`); + assert(result.value.frame === 'response', 'expected response frame'); +}); + +Deno.test('Rust-serialized compatibility fixtures satisfy the generated Browser contract', () => { + for (const fixture of RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES) { + const result = decode(fixture); + assert(result.ok, `Rust fixture was rejected: ${JSON.stringify(result)}`); + } +}); + +Deno.test('generated subscription validator rejects malformed and newer shapes', () => { + const cases: Array<[string, unknown]> = [ + ['malformed json', '{'], + ['invalid discriminator', { ...subscribedFrame(), frame: 'future' }], + ['missing required field', { frame: 'response', message: subscribedFrame().message }], + ['unknown root field', { ...subscribedFrame(), secret: 'must-not-reflect' }], + [ + 'unknown nested field', + subscribedFrame({ snapshot: { topic: 'workers', data: { workers: [], future: true } } }), + ], + ['wrong field type', subscribedFrame({ snapshot_revision: '1' })], + ['unsafe integer', subscribedFrame({ snapshot_revision: Number.MAX_SAFE_INTEGER + 1 })], + [ + 'missing serialized Worker defaults', + subscribedFrame({ + snapshot: { + topic: 'workers', + data: { + workers: [ + { worker_id: 'worker-1', subject_revision: 1, state: 'idle' }, + ], + }, + }, + }), + ], + ['unsupported version', { ...subscribedFrame(), protocol_version: 2 }], + [ + 'newer Worker event', + { + protocol_version: 1, + frame: 'event', + message: { + event: 'event', + data: { + subscription_id: 'subscription-1', + subject_revision: 2, + payload: { + event: 'worker_protocol', + data: { worker_id: 'worker-1', event: { event: 'future_event' } }, + }, + }, + }, + }, + ], + [ + 'out-of-range Rust u32', + { + protocol_version: 1, + frame: 'event', + message: { + event: 'event', + data: { + subscription_id: 'subscription-1', + subject_revision: 2, + payload: { + event: 'worker_protocol', + data: { + worker_id: 'worker-1', + event: { + event: 'llm_retry', + data: { + llm_call: 1, + failed_attempt: 4_294_967_296, + max_attempts: 2, + wait_ms: 1, + elapsed_ms: 1, + error: 'retry', + }, + }, + }, + }, + }, + }, + }, + ], + ]; + + for (const [name, value] of cases) { + const result = typeof value === 'string' ? decodeSubscriptionFrame(value) : decode(value); + assert(!result.ok, `${name} unexpectedly passed`); + assert(!JSON.stringify(result).includes('must-not-reflect'), `${name} reflected frame content`); + } +}); + +Deno.test('generated subscription validator enforces Rust semantic bounds and selector pairing', () => { + const oversizedIds = Array.from({ length: 257 }, (_, index) => `worker-${index}`); + const cases: Array<[string, unknown]> = [ + [ + 'oversized correlation id', + subscribedFrame({ subscription_id: 'x'.repeat(129) }), + ], + [ + 'oversized rejection message', + { + protocol_version: 1, + frame: 'response', + message: { + result: 'subscription_rejected', + payload: { + request_id: 'request-1', + code: 'invalid_request', + message: 'x'.repeat(1025), + }, + }, + }, + ], + [ + 'oversized selector collection', + subscribedFrame({ + selector: { topic: 'worker_lifecycle', worker_ids: oversizedIds }, + }), + ], + [ + 'selector snapshot mismatch', + subscribedFrame({ + selector: { topic: 'worker_protocol', worker_id: 'worker-1' }, + }), + ], + [ + 'duplicate snapshot worker', + subscribedFrame({ + snapshot: { + topic: 'workers', + data: { + workers: [ + { + worker_id: 'worker-1', + runtime_id: 'runtime-1', + availability: 'observed', + subject_revision: 1, + state: 'idle', + has_running_internal_workers: false, + }, + { + worker_id: 'worker-1', + runtime_id: 'runtime-1', + availability: 'observed', + subject_revision: 2, + state: 'running', + has_running_internal_workers: false, + }, + ], + }, + }, + }), + ], + [ + 'server-to-Browser direction mismatch', + { + protocol_version: 1, + frame: 'request', + message: { + method: 'unsubscribe_events', + params: { request_id: 'request-1', subscription_id: 'subscription-1' }, + }, + }, + ], + ]; + + for (const [name, value] of cases) { + const result = decode(value); + assert(!result.ok, `${name} unexpectedly passed`); + } +}); + +Deno.test('aggregate limits include keys in unconstrained Worker JSON values', () => { + const oversizedKey = 'k'.repeat(MAX_SUBSCRIPTION_STRING_BYTES + 1); + const result = decode({ + protocol_version: 1, + frame: 'event', + message: { + event: 'event', + data: { + subscription_id: 'subscription-1', + subject_revision: 2, + payload: { + event: 'worker_protocol', + data: { + worker_id: 'worker-1', + event: { + event: 'system_item', + data: { item: { [oversizedKey]: true } }, + }, + }, + }, + }, + }, + }); + assert(!result.ok && result.reason === 'aggregate_limit', 'oversized object key passed'); +}); + +Deno.test('stateful selector validation fences a routed event before projection', () => { + const frame: SubscriptionFrame = { + protocol_version: 1, + frame: 'event', + message: { + event: 'event', + data: { + subscription_id: 'subscription-1', + subject_revision: 2, + payload: { + event: 'worker_protocol', + data: { worker_id: 'worker-2', event: { event: 'thinking_start' } }, + }, + }, + }, + }; + const decoded = decode(frame); + assert(decoded.ok, `expected structurally valid event, got ${JSON.stringify(decoded)}`); + const selector: EventSubscriptionSelector = { + topic: 'worker_protocol', + worker_id: 'worker-1', + }; + assert( + !subscriptionFrameMatchesSelector(decoded.value, selector), + 'mismatched Worker event crossed its subscription selector', + ); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index d5215ba3f..689bf445f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -71,7 +71,7 @@ const loadedRepositories = $derived(data.repositories.data); let ticket = $state(loadedTicket); - const mergeRequest = $derived(ticket.merge_request); + const mergeRequests = $derived(ticket.merge_requests); let editing = $state(false); let editTitle = $state(loadedTicket.title); let editBody = $state(loadedTicket.body); @@ -682,28 +682,32 @@

-

Merge Request

- {#if mergeRequest} -

{mergeRequest.state}

-

- From {mergeRequest.selector_from ?? "requires repair"} - to {mergeRequest.selector_to} -

- {#if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref === mergeRequest.current_subject_ref} -

Source review: {mergeRequest.review_status} for exact ref {mergeRequest.current_subject_ref}

- {:else if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref} -

Fresh source review required: selector_from moved from {mergeRequest.review_subject_ref} to {mergeRequest.current_subject_ref}.

- {:else if mergeRequest.current_subject_ref} -

Fresh source review required: no effective verdict exists for {mergeRequest.current_subject_ref}.

- {:else} -

Source review unavailable: selector_from is unresolved.

- {/if} -

Target integration: {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.

-

Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.

- Open Merge Request +

Merge Requests

+ {#if mergeRequests.length > 0} + {#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)} +
+

{mergeRequest.repository_key}: {mergeRequest.state}

+

+ From {mergeRequest.selector_from ?? "requires repair"} + to {mergeRequest.selector_to} +

+ {#if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref === mergeRequest.current_subject_ref} +

Source review: {mergeRequest.review_status} for exact ref {mergeRequest.current_subject_ref}

+ {:else if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref} +

Fresh source review required: selector_from moved from {mergeRequest.review_subject_ref} to {mergeRequest.current_subject_ref}.

+ {:else if mergeRequest.current_subject_ref} +

Fresh source review required: no effective verdict exists for {mergeRequest.current_subject_ref}.

+ {:else} +

Source review unavailable: selector_from is unresolved.

+ {/if} +

Target integration: {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.

+

Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.

+ Open Merge Request +
+ {/each} {:else}

The assigned Coder has not opened a Merge Request.

{/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 8f826cc0e..2b5abd0ac 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -1,6 +1,7 @@ @@ -1836,9 +1992,14 @@

{workerError}

{/if} - {#if lines.length === 0} -

No console output is available for this Worker yet.

- {:else} + 0} + hasUnfilteredContent={selectedConsoleProjection.lines.length > 0} + onRetry={retryConsoleLoad} + /> + + {#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
    {#each lines as item (item.id)} @@ -2182,6 +2343,9 @@ } .console-card { + display: grid; + align-content: start; + gap: var(--space-4); min-height: 100%; } diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts new file mode 100644 index 000000000..7035e6a37 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -0,0 +1,349 @@ +// @vitest-environment happy-dom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import type { + Event as ProtocolEvent, + SessionSnapshot, +} from "$lib/generated/protocol"; +import type { Worker } from "$lib/workspace/sidebar/types"; +import ConsolePage from "./+page.svelte"; + +const multiplexer = vi.hoisted(() => { + type Listener = { + onFrame(frame: unknown): void; + onStatus?(status: "connecting" | "open" | "closed", message?: string): void; + }; + const listeners: Listener[] = []; + return { + listeners, + close: vi.fn(), + sendWorkerMethod: vi.fn(), + subscribe: vi.fn((_selector: unknown, listener: Listener) => { + listeners.push(listener); + listener.onStatus?.("connecting"); + return { + close: multiplexer.close, + sendWorkerMethod: multiplexer.sendWorkerMethod, + }; + }), + }; +}); + +vi.mock("$lib/workspace/multiplexer", () => ({ + workspaceMultiplexer: () => ({ subscribe: multiplexer.subscribe }), +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function worker(workerId = "worker-a", runtimeId = "runtime-a"): Worker { + return { + worker_id: workerId, + runtime_id: runtimeId, + resource_key: `worker:${workerId}`, + display_name: workerId, + label: workerId, + state: "idle", + host_id: "host-a", + profile: "default", + tags: [], + diagnostics: [], + capabilities: { + can_stop: true, + can_spawn_followup: true, + }, + workspace: { visibility: "workspace", identity: "workspace-a" }, + implementation: { kind: "builtin", display_hint: "test" }, + workdir_attachments: [], + } as unknown as Worker; +} + +function pageData(workerId = "worker-a", runtimeId = "runtime-a") { + return { + workspaceId: "workspace-a", + runtimeId, + workerId, + worker: worker(workerId, runtimeId), + workerError: null, + }; +} + +function emptySession(): SessionSnapshot { + return { + pending_submissions: { + revision: 0, + notification_count: 0, + head_id: null, + submissions: [], + }, + entries: [], + }; +} + +function sessionWithUserMessage(content: string): SessionSnapshot { + return { + ...emptySession(), + entries: [{ + kind: "user_input", + segments: [{ kind: "text", content }], + entry_id: `entry-${content}`, + timestamp: 1, + provenance: "human_input", + derived_from: [], + }], + }; +} + +function snapshotEvent(session = emptySession()): ProtocolEvent { + return { + event: "snapshot", + data: { + session, + greeting: { + worker_name: "worker-a", + cwd: "", + provider: "test", + model: "test", + scope_summary: "test", + tools: [], + context_window: 0, + context_tokens: 0, + }, + state: { last_command_id: 0, state: { kind: "idle" } }, + in_flight: { blocks: [], commands: [], compaction: null }, + internal_workers: [], + }, + }; +} + +function subscribedFrame(session = emptySession()) { + return { + frame: "response", + message: { + result: "subscribed", + payload: { + snapshot: { + topic: "worker_protocol", + data: { events: [snapshotEvent(session)] }, + }, + }, + }, + }; +} + +function latestListener() { + const listener = multiplexer.listeners.at(-1); + if (!listener) throw new Error("missing Worker protocol listener"); + return listener; +} + +async function settleMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + multiplexer.listeners.length = 0; + multiplexer.close.mockClear(); + multiplexer.sendWorkerMethod.mockClear(); + multiplexer.subscribe.mockClear(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test("keeps delayed Session and live initial snapshot work in loading state until projection applies", async () => { + const sessionResponse = deferred(); + vi.stubGlobal("fetch", vi.fn(() => sessionResponse.promise)); + render(ConsolePage, { data: pageData() }); + + expect(screen.getByRole("status").textContent).toContain( + "Loading conversation", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); + + sessionResponse.resolve(Response.json({ availability: "live_protocol" })); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + expect(screen.getByRole("status").textContent).toContain( + "Waiting for the initial conversation snapshot", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); + + latestListener().onFrame(subscribedFrame(emptySession())); + expect(await screen.findByText("No conversation to display")).not.toBeNull(); + expect(screen.getByText("This Worker view has no conversation history yet.")) + .not + .toBeNull(); +}); + +test("applies retained empty and nonempty snapshots before showing their read-only result", async () => { + const response = deferred(); + const nextResponse = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const pending = String(input).includes("worker-b") + ? nextResponse.promise + : response.promise; + return pending.then((value) => value.clone()); + }), + ); + const view = render(ConsolePage, { data: pageData() }); + + expect(screen.getByRole("status").textContent).toContain( + "Loading conversation", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); + response.resolve(Response.json({ + availability: "retained_snapshot", + snapshot: emptySession(), + })); + expect(await screen.findByText("Read-only retained conversation")).not + .toBeNull(); + expect(screen.getByText("No conversation to display")).not.toBeNull(); + + await view.rerender({ data: pageData("worker-b", "runtime-b") }); + expect(screen.getByRole("status").textContent).toContain( + "Loading conversation", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); + nextResponse.resolve(Response.json({ + availability: "retained_snapshot", + snapshot: sessionWithUserMessage("retained message"), + })); + + expect(await screen.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({ + availability: "retained_snapshot", + snapshot: emptySession(), + })); + vi.stubGlobal("fetch", fetchMock); + render(ConsolePage, { data: pageData() }); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Unable to load conversation"); + expect(alert.textContent).toContain("failed to observe Worker Session (503)"); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(await screen.findByText("No conversation to display")).not.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + +test("distinguishes typed unavailability and an initial live connection closure", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(Response.json({ + availability: "unavailable", + message: "retained state expired", + })) + .mockResolvedValueOnce(Response.json({ availability: "live_protocol" })); + vi.stubGlobal("fetch", fetchMock); + render(ConsolePage, { data: pageData() }); + + expect((await screen.findByRole("alert")).textContent).toContain( + "Conversation unavailable retained state expired", + ); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onStatus?.("closed", "subscription ended before snapshot"); + + expect((await screen.findByRole("alert")).textContent).toContain( + "subscription ended before snapshot", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); +}); + +test("preserves rendered content while reconnecting", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(Response.json({ availability: "live_protocol" })) + ), + ); + render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("kept message")), + ); + expect(await screen.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(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(); +}); + +test("route changes clear prior content and fence stale Session responses", async () => { + const staleReload = deferred(); + const current = deferred(); + let firstWorkerLoads = 0; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).includes("worker-b")) { + return current.promise.then((value) => value.clone()); + } + firstWorkerLoads += 1; + if (firstWorkerLoads === 1) { + return Promise.resolve(Response.json({ availability: "live_protocol" })); + } + return staleReload.promise.then((value) => value.clone()); + }); + vi.stubGlobal("fetch", fetchMock); + const view = render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("previous worker content")), + ); + expect(await screen.findByText("previous worker content")).not.toBeNull(); + + latestListener().onStatus?.("closed", "reload old worker"); + await fireEvent.click( + await screen.findByRole("button", { name: "Retry" }), + ); + await waitFor(() => expect(firstWorkerLoads).toBe(2)); + await view.rerender({ data: pageData("worker-b", "runtime-b") }); + expect(screen.queryByText("previous worker content")).toBeNull(); + expect(screen.getByRole("status").textContent).toContain( + "Loading conversation", + ); + + current.resolve(Response.json({ + availability: "retained_snapshot", + snapshot: sessionWithUserMessage("current worker content"), + })); + expect(await screen.findByText("current worker content")).not.toBeNull(); + + staleReload.resolve(Response.json({ + availability: "unavailable", + message: "stale worker error", + })); + await settleMicrotasks(); + expect(screen.queryByText("stale worker error")).toBeNull(); + expect(screen.queryByText("previous worker content")).toBeNull(); + expect(screen.getByText("current worker content")).not.toBeNull(); +});