chore: merge develop into hare/develop
This commit is contained in:
@@ -154,6 +154,119 @@ pub fn open_beneath_no_symlinks(_root: &Path, _path: &Path) -> std::io::Result<s
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AtomicWriteMode {
|
||||
CreateNew,
|
||||
Replace,
|
||||
}
|
||||
|
||||
#[cfg(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<()> {
|
||||
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::<OsString>;
|
||||
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<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Bound provider-owned Edit result cardinality before mutation.
|
||||
fn max_edit_replacements(&self) -> Option<usize> {
|
||||
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::Metadata> {
|
||||
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();
|
||||
|
||||
@@ -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<Vec<u8>, 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<PathBuf, FsError> {
|
||||
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<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
+521
-52
@@ -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<MergeRequestReviewSubject>,
|
||||
pub requested_by: WorkerIdentity,
|
||||
pub reviewer: WorkerIdentity,
|
||||
pub created_at: DateTime<Utc>,
|
||||
@@ -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<MergeRequestReviewSubject>,
|
||||
pub decision: ReviewDecision,
|
||||
pub body: String,
|
||||
pub findings: Vec<ReviewFinding>,
|
||||
@@ -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<MergeRequestReviewSubject>,
|
||||
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<String>,
|
||||
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<Utc>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompleteTicket {
|
||||
pub ticket_id: String,
|
||||
pub operation_id: String,
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub requirement_approval_event_id: String,
|
||||
pub auth: MergeRequestAuth,
|
||||
pub now: DateTime<Utc>,
|
||||
}
|
||||
#[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<String>,
|
||||
pub requirement_approval_event_id: String,
|
||||
pub completed_by: WorkerIdentity,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
#[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<RequestedMergeRequestReview, MergeRequestError> {
|
||||
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::<Vec<_>>();
|
||||
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<ReviewSubmissionAuthorization, MergeRequestError> {
|
||||
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<ReviewRevokedEvent, MergeRequestError> {
|
||||
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<ReadinessReport, 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)?;
|
||||
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<String> = 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<String> = 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<String> = 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<TicketCompletionEvent, MergeRequestError> {
|
||||
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<String> = 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<MergeRequest, MergeRequestError> {
|
||||
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<MergeRequest, MergeRequestError> {
|
||||
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<MergeRequest, MergeRequestError> {
|
||||
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<Vec<MergeRequest>, 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<T: Serialize>(
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn load_mr_for_ticket(
|
||||
fn linked_merge_request_ids(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<MergeRequest>, MergeRequestError> {
|
||||
let merge_request_id: Option<String> = connection
|
||||
) -> Result<Vec<String>, 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::<Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn current_ticket_revision(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<String>, 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<Option<MergeRequest>, 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),
|
||||
|
||||
@@ -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<bool, String> {
|
||||
Ok(w == "W" && r == "R")
|
||||
Ok(w == "W" && matches!(r, "R" | "R2"))
|
||||
}
|
||||
}
|
||||
fn at(s: u32) -> chrono::DateTime<Utc> {
|
||||
@@ -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<_>>(),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
#[cfg(feature = "typescript")]
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WorkerStatus> 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<Segment>,
|
||||
@@ -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<String>,
|
||||
@@ -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<InFlightBlock>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SubscriptionWorkerId>);
|
||||
|
||||
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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repository_key: Option<String>,
|
||||
@@ -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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repository_key: Option<String>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<SubscriptionFrame> {
|
||||
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<String, Value>) {
|
||||
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<String, Value>) {
|
||||
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<String, Value>,
|
||||
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<String, Value>) {
|
||||
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::<Vec<_>>();
|
||||
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<String, Value>) {
|
||||
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::<Vec<_>>();
|
||||
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`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
const workdirIds = new Set<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<PublicMergeRequest, RepositoryApiError>;
|
||||
#[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<MergeRequestReadinessResponse, RepositoryApiError>;
|
||||
#[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<MergeRequestThreadResponse, RepositoryApiError>;
|
||||
#[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<ReviewEvent, RepositoryApiError>;
|
||||
#[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<ReviewRevokedEvent, RepositoryApiError>;
|
||||
#[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<MergeEvent, RepositoryApiError>;
|
||||
#[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<TicketCompletionEvent, RepositoryApiError>;
|
||||
#[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<workdir::workspace::WorkingDirectorySummary> 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<u64>,
|
||||
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<String>,
|
||||
#[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<TicketMergeRequestSummary>,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
}
|
||||
@@ -5939,6 +5966,7 @@ pub struct TicketDetail {
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
pub assignment_diagnostics: Vec<String>,
|
||||
pub action_eligibility: TicketActionEligibility,
|
||||
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub resolution: Option<String>,
|
||||
@@ -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<MergeRequestReviewSubject>,
|
||||
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<MergeRequestReviewSubject>,
|
||||
decision: ReviewDecision,
|
||||
body: String,
|
||||
findings: Vec<ReviewFinding>,
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
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",
|
||||
|
||||
+44
-41
@@ -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]
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -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<WorkdirSessionOperation> for ExternalWorkdirOperation {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(operation: WorkdirSessionOperation) -> Result<Self, Self::Error> {
|
||||
validate_read_only_operation(&operation)?;
|
||||
validate_filesystem_operation(&operation)?;
|
||||
Ok(Self(operation))
|
||||
}
|
||||
}
|
||||
@@ -224,7 +239,7 @@ fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> 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<WorkdirSessionOperationResult> for ExternalWorkdirOperationResult {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(result: WorkdirSessionOperationResult) -> Result<Self, Self::Error> {
|
||||
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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+108
-11
@@ -347,6 +347,62 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
}
|
||||
}
|
||||
|
||||
fn max_write_bytes(&self) -> Option<usize> {
|
||||
self.reject_symlinks
|
||||
.then_some(crate::external::MAX_EXTERNAL_WRITE_BYTES)
|
||||
}
|
||||
|
||||
fn max_edit_replacements(&self) -> Option<usize> {
|
||||
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<std::fs::ReadDir> {
|
||||
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<Path>,
|
||||
@@ -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<Path>,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
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, WorkdirError> {
|
||||
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, WorkdirError> {
|
||||
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<Self, WorkdirError> {
|
||||
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),
|
||||
|
||||
@@ -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<WorkerObservationEvent, RuntimeError> + Send + Sync,
|
||||
dyn Fn(WorkerRef, protocol::Event) -> Result<WorkerObservationEvent, RuntimeError>
|
||||
+ 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,
|
||||
|
||||
@@ -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<WorkerAggregateRecord, RuntimeError> {
|
||||
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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1016,12 +1016,12 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<session_store::RetainedSessionSnapshot, session_store::RetainedSnapshotReadError>
|
||||
{
|
||||
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<PathBuf, Vec<u8>> {
|
||||
fn collect(root: &Path, current: &Path, files: &mut BTreeMap<PathBuf, Vec<u8>>) {
|
||||
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();
|
||||
|
||||
@@ -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<String>,
|
||||
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<u32>,
|
||||
body: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TicketMergeRequestProjection {
|
||||
merge_request: Option<TicketMergeRequestReference>,
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>() }),
|
||||
@@ -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<ToolOutput, ToolError> {
|
||||
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<String>, Vec<String>) {
|
||||
@@ -596,7 +600,8 @@ mod tests {
|
||||
[
|
||||
"ShowMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"CompleteMergeRequest"
|
||||
"CompleteMergeRequest",
|
||||
"CompleteTicket"
|
||||
]
|
||||
);
|
||||
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
||||
|
||||
@@ -27,6 +27,7 @@ struct ModelTicketQueryItem {
|
||||
unresolved_blocker_count: usize,
|
||||
unresolved_review_count: usize,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
merge_requests: Vec<ModelMergeRequest>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ pub(super) struct ModelTicketDetail {
|
||||
assignments: Vec<ModelAssignment>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||
merge_requests: Vec<ModelMergeRequest>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
actions: Option<ModelTicketActions>,
|
||||
@@ -162,6 +164,8 @@ struct ModelEvidenceEvent {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelMergeRequest {
|
||||
merge_request_id: String,
|
||||
repository_key: String,
|
||||
state: String,
|
||||
selector_from: Option<String>,
|
||||
selector_to: String,
|
||||
@@ -251,6 +255,10 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
unresolved_blocker_count: usize_field(item, "unresolved_blocker_count")?,
|
||||
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
|
||||
evidence: item.get("evidence").map(project_evidence).transpose()?,
|
||||
merge_requests: array_field(item, "merge_requests")?
|
||||
.iter()
|
||||
.map(project_merge_request)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
merge_request: item
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
@@ -295,6 +303,10 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
.iter()
|
||||
.map(project_evidence_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
merge_requests: array_field(root, "merge_requests")?
|
||||
.iter()
|
||||
.map(project_merge_request)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
merge_request: root
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
@@ -498,11 +510,13 @@ fn project_evidence_event(value: &Value) -> Result<ModelEvidenceEvent, String> {
|
||||
fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
|
||||
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,
|
||||
|
||||
@@ -721,6 +721,7 @@ mod tests {
|
||||
"ReviewMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"CompleteMergeRequest",
|
||||
"CompleteTicket",
|
||||
] {
|
||||
assert!(combined.contains(operation), "missing {operation}");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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::<Result<Vec<_>>>()?;
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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<DateTime<Utc>> {
|
||||
.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::<Vec<_>>();
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String>,
|
||||
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<_>>(),
|
||||
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());
|
||||
|
||||
+100
-68
@@ -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<Duration, ParseError> {
|
||||
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::<u64>().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<String>,
|
||||
@@ -1857,7 +1862,7 @@ Usage:
|
||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
||||
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
|
||||
yoi [--backend <URL>] login [--no-wait]
|
||||
yoi [--backend <URL>] workdir share <PATH> --workspace-id <ID> --read-only --ttl <TTL> --display-name <NAME>
|
||||
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
|
||||
yoi <HOST_COMMAND> [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 <URL>] workdir share <PATH> --workspace-id <ID> --read-only --ttl <TTL> --display-name <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 <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, 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 <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 <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
+397
-22
@@ -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<String>,
|
||||
pub backend_url: String,
|
||||
pub display_name: String,
|
||||
pub ttl: Duration,
|
||||
pub ttl: Option<Duration>,
|
||||
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<Duration>,
|
||||
permission: server_api::ExternalWorkdirPermission,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_ttl(value: &str) -> Result<Duration, String> {
|
||||
let (number, multiplier) = if let Some(value) = value.strip_suffix('h') {
|
||||
(value, 60 * 60)
|
||||
} else if let Some(value) = value.strip_suffix('m') {
|
||||
(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::<u64>()
|
||||
.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<Duration>) -> 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<Option<String>, 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<Option<ResolvedWorkdirShareOptions>, 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::<usize>()
|
||||
.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<Option<ResolvedWorkdirShareOptions>, 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::<u8>::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
|
||||
|
||||
Reference in New Issue
Block a user