test: isolate local rules and reduce repeated fixture setup
This commit is contained in:
+10
@@ -91,6 +91,16 @@ debug = "line-tables-only"
|
|||||||
[profile.dev.package."*"]
|
[profile.dev.package."*"]
|
||||||
debug = false
|
debug = false
|
||||||
|
|
||||||
|
# Keep the real SQLite engine in schema/transaction tests without paying for
|
||||||
|
# an unoptimized C interpreter on every statement. Rust test assertions stay enabled.
|
||||||
|
[profile.test.package.libsqlite3-sys]
|
||||||
|
opt-level = 2
|
||||||
|
|
||||||
|
# Schema/authority integration tests execute substantial parsing and validation.
|
||||||
|
# Optimize that code without disabling test/debug assertions or overflow checks.
|
||||||
|
[profile.test.package.yoi-workspace-server]
|
||||||
|
opt-level = 1
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Internal crates
|
# Internal crates
|
||||||
client = { path = "crates/client" }
|
client = { path = "crates/client" }
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
|
//! Compile the real public macro through a renamed dependency in an isolated consumer.
|
||||||
|
//! Only Cargo artifacts are shared; Cargo checks the fresh consumer on every invocation.
|
||||||
|
|
||||||
use std::{fs, path::PathBuf, process::Command};
|
use std::{fs, path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
struct Fixture(PathBuf);
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
for attempt in 0..1000 {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"api-macros-renamed-dependency-{}-{attempt}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
match fs::create_dir(&path) {
|
||||||
|
Ok(()) => return Self(path),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(error) => panic!("create renamed dependency fixture: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("could not allocate a renamed dependency fixture directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn macro_expansion_resolves_a_renamed_support_dependency() {
|
fn macro_expansion_resolves_a_renamed_support_dependency() {
|
||||||
let fixture = std::env::temp_dir().join(format!(
|
let fixture = Fixture::new();
|
||||||
"api-macros-renamed-dependency-{}",
|
let fixture = &fixture.0;
|
||||||
std::process::id()
|
fs::create_dir(fixture.join("src")).expect("create fixture source directory");
|
||||||
));
|
|
||||||
let _ = fs::remove_dir_all(&fixture);
|
|
||||||
fs::create_dir_all(fixture.join("src")).expect("create fixture source directory");
|
|
||||||
|
|
||||||
let package_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
let package_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
let package_path = package_path
|
let package_path = package_path
|
||||||
@@ -49,13 +74,21 @@ pub fn operation_count() -> usize {
|
|||||||
)
|
)
|
||||||
.expect("write fixture source");
|
.expect("write fixture source");
|
||||||
|
|
||||||
|
// Keep nested Cargo checks off the outer build's target lock. Cargo owns cache
|
||||||
|
// invalidation and locking between concurrent consumers, not a saved
|
||||||
|
// test-success marker.
|
||||||
|
let executable = std::env::current_exe().expect("current test executable");
|
||||||
|
let target_dir = executable
|
||||||
|
.parent()
|
||||||
|
.and_then(|deps| deps.parent())
|
||||||
|
.expect("test executable has a profile directory")
|
||||||
|
.join("compile-tests/api-macros-renamed");
|
||||||
let output = Command::new(env!("CARGO"))
|
let output = Command::new(env!("CARGO"))
|
||||||
.args(["check", "--quiet", "--offline"])
|
.args(["check", "--quiet", "--offline"])
|
||||||
.current_dir(&fixture)
|
.current_dir(fixture)
|
||||||
.env("CARGO_TARGET_DIR", fixture.join("target"))
|
.env("CARGO_TARGET_DIR", target_dir)
|
||||||
.output()
|
.output()
|
||||||
.expect("run cargo check for renamed dependency fixture");
|
.expect("run cargo check for renamed dependency fixture");
|
||||||
let _ = fs::remove_dir_all(&fixture);
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
output.status.success(),
|
output.status.success(),
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//! Check the shipped templates against the real PDK and WIT for the guest target.
|
||||||
|
//! Each check owns its source and lockfile; Cargo alone shares and validates artifacts.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -135,36 +138,86 @@ fn embedded_service_template_cargo_checks_for_wasm_target() {
|
|||||||
|
|
||||||
fn cargo_check_template(template: &str) {
|
fn cargo_check_template(template: &str) {
|
||||||
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
let template_dir = crate_dir.join(format!("../../resources/plugin/templates/{template}"));
|
let templates_dir = crate_dir.join("../../resources/plugin/templates");
|
||||||
let manifest_path = template_dir.join("Cargo.toml");
|
let template_dir = templates_dir.join(template);
|
||||||
let lock_path = template_dir.join("Cargo.lock");
|
|
||||||
let _ = fs::remove_file(&lock_path);
|
|
||||||
|
|
||||||
let target_dir = tempfile::tempdir().expect("temporary cargo target dir");
|
// A sibling fixture preserves the shipped relative PDK and WIT paths verbatim.
|
||||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
|
// Never create or remove a lockfile in the shipped template: another invocation
|
||||||
let output = Command::new(cargo)
|
// may be checking it at the same time, or a developer may own that lockfile.
|
||||||
|
let fixture = tempfile::Builder::new()
|
||||||
|
.prefix(".cargo-check-")
|
||||||
|
.tempdir_in(&templates_dir)
|
||||||
|
.expect("temporary template fixture");
|
||||||
|
copy_template_tree(&template_dir, fixture.path(), true);
|
||||||
|
|
||||||
|
// Both templates share the PDK dependencies. A separate persistent target
|
||||||
|
// reuses those artifacts without sharing source or the outer Cargo build lock.
|
||||||
|
// Cargo fingerprints and its target lock handle changes and concurrent checks.
|
||||||
|
let executable = std::env::current_exe().expect("current test executable");
|
||||||
|
let target_dir = executable
|
||||||
|
.parent()
|
||||||
|
.and_then(|deps| deps.parent())
|
||||||
|
.expect("test executable has a profile directory")
|
||||||
|
.join("compile-tests/plugin-pdk-templates");
|
||||||
|
let output = Command::new(env!("CARGO"))
|
||||||
.arg("check")
|
.arg("check")
|
||||||
.arg("--manifest-path")
|
.arg("--manifest-path")
|
||||||
.arg(&manifest_path)
|
.arg(fixture.path().join("Cargo.toml"))
|
||||||
.arg("--target")
|
.arg("--target")
|
||||||
.arg("wasm32-unknown-unknown")
|
.arg("wasm32-unknown-unknown")
|
||||||
.arg("--offline")
|
.arg("--offline")
|
||||||
.arg("--target-dir")
|
.arg("--target-dir")
|
||||||
.arg(target_dir.path())
|
.arg(target_dir)
|
||||||
|
.current_dir(fixture.path())
|
||||||
.env("CARGO_TERM_COLOR", "never")
|
.env("CARGO_TERM_COLOR", "never")
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn cargo check for embedded template");
|
.expect("spawn cargo check for embedded template");
|
||||||
|
|
||||||
let _ = fs::remove_file(&lock_path);
|
|
||||||
assert!(
|
assert!(
|
||||||
output.status.success(),
|
output.status.success(),
|
||||||
"template cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
|
"template {template} cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
|
||||||
output.status,
|
output.status,
|
||||||
String::from_utf8_lossy(&output.stdout),
|
String::from_utf8_lossy(&output.stdout),
|
||||||
String::from_utf8_lossy(&output.stderr)
|
String::from_utf8_lossy(&output.stderr)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_template_tree(source: &Path, destination: &Path, is_root: bool) {
|
||||||
|
let metadata = fs::symlink_metadata(source).expect("read template directory metadata");
|
||||||
|
assert!(
|
||||||
|
metadata.is_dir() && !metadata.file_type().is_symlink(),
|
||||||
|
"template input must be a real directory: {}",
|
||||||
|
source.display()
|
||||||
|
);
|
||||||
|
for entry in fs::read_dir(source).expect("read template directory") {
|
||||||
|
let entry = entry.expect("read template entry");
|
||||||
|
let path = entry.path();
|
||||||
|
let kind = entry.file_type().expect("read template entry type");
|
||||||
|
assert!(
|
||||||
|
!kind.is_symlink() && (kind.is_dir() || kind.is_file()),
|
||||||
|
"unsupported template input (including symlinks): {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let name = entry.file_name();
|
||||||
|
// Preserve all compiler inputs, including build scripts and nested assets.
|
||||||
|
// Only omit the root build output and lockfile: each isolated consumer must
|
||||||
|
// resolve its own lockfile, as the original unlocked template check did.
|
||||||
|
if is_root
|
||||||
|
&& ((kind.is_dir() && name == "target") || (kind.is_file() && name == "Cargo.lock"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let copied = destination.join(name);
|
||||||
|
if kind.is_dir() {
|
||||||
|
fs::create_dir(&copied).expect("create template fixture directory");
|
||||||
|
copy_template_tree(&path, &copied, false);
|
||||||
|
} else {
|
||||||
|
fs::copy(&path, &copied).expect("copy unmodified template input");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pdk_runtime_dependencies_are_guest_side_only() {
|
fn pdk_runtime_dependencies_are_guest_side_only() {
|
||||||
let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses");
|
let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses");
|
||||||
|
|||||||
@@ -360,10 +360,52 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bounded_binary_read_accepts_arbitrary_bytes_at_caller_limit_and_rejects_larger_files()
|
async fn bounded_binary_read_accepts_caller_limits_above_the_image_tool_limit() {
|
||||||
{
|
// The caller's limit is a reader policy, not a LocalWorkdir storage contract.
|
||||||
|
// Supply immutable chunks here; the separate component case exercises the
|
||||||
|
// real provider across a chunk boundary without repeatedly hashing 16 MiB.
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let bytes = vec![0xff; 16 * 1024 * 1024];
|
let bytes = vec![0xff; 16 * 1024 * 1024];
|
||||||
|
let hash = Sha256::digest(&bytes).into();
|
||||||
|
let responses = bytes
|
||||||
|
.chunks(IMAGE_READ_CHUNK_BYTES)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, bytes)| {
|
||||||
|
chunk(
|
||||||
|
bytes,
|
||||||
|
(index * IMAGE_READ_CHUNK_BYTES) as u64,
|
||||||
|
16 * 1024 * 1024,
|
||||||
|
hash,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let session = binary_session(&dir, responses);
|
||||||
|
let path = WorkdirPath::new("artifact.bin").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_workdir_bytes(&session, path.clone(), bytes.len())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
bytes
|
||||||
|
);
|
||||||
|
let session = binary_session(
|
||||||
|
&dir,
|
||||||
|
vec![chunk(
|
||||||
|
&bytes[..IMAGE_READ_CHUNK_BYTES],
|
||||||
|
0,
|
||||||
|
bytes.len() as u64,
|
||||||
|
hash,
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
read_workdir_bytes(&session, path, bytes.len() - 1).await,
|
||||||
|
Err(WorkdirError::InvalidArgument(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bounded_binary_read_reassembles_provider_chunks_and_enforces_the_caller_limit() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let bytes = vec![0xff; IMAGE_READ_CHUNK_BYTES + 1];
|
||||||
std::fs::write(dir.path().join("artifact.bin"), &bytes).unwrap();
|
std::fs::write(dir.path().join("artifact.bin"), &bytes).unwrap();
|
||||||
let session =
|
let session =
|
||||||
LocalWorkdirSession::new(Scope::writable(dir.path()).unwrap(), dir.path().into());
|
LocalWorkdirSession::new(Scope::writable(dir.path()).unwrap(), dir.path().into());
|
||||||
|
|||||||
@@ -59,5 +59,5 @@ futures.workspace = true
|
|||||||
agen.workspace = true
|
agen.workspace = true
|
||||||
serial_test = "3.4.0"
|
serial_test = "3.4.0"
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
|
|||||||
@@ -2341,6 +2341,7 @@ pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
|
|||||||
pending_restores: Mutex<HashMap<WorkerRef, Arc<PendingRuntimeRestore>>>,
|
pending_restores: Mutex<HashMap<WorkerRef, Arc<PendingRuntimeRestore>>>,
|
||||||
worker_locks: Mutex<HashMap<WorkerRef, Arc<Mutex<()>>>>,
|
worker_locks: Mutex<HashMap<WorkerRef, Arc<Mutex<()>>>>,
|
||||||
spawn_restore_timeout: Duration,
|
spawn_restore_timeout: Duration,
|
||||||
|
shutdown_admission_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<F> WorkerRuntimeExecutionBackend<F>
|
impl<F> WorkerRuntimeExecutionBackend<F>
|
||||||
@@ -2362,6 +2363,7 @@ where
|
|||||||
pending_restores: Mutex::new(HashMap::new()),
|
pending_restores: Mutex::new(HashMap::new()),
|
||||||
worker_locks: Mutex::new(HashMap::new()),
|
worker_locks: Mutex::new(HashMap::new()),
|
||||||
spawn_restore_timeout: SPAWN_RESTORE_TASK_TIMEOUT,
|
spawn_restore_timeout: SPAWN_RESTORE_TASK_TIMEOUT,
|
||||||
|
shutdown_admission_timeout: Duration::from_secs(5),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2881,9 +2883,10 @@ where
|
|||||||
let shutdown_requested = Arc::clone(shutdown_requested);
|
let shutdown_requested = Arc::clone(shutdown_requested);
|
||||||
let shutdown = shutdown.clone();
|
let shutdown = shutdown.clone();
|
||||||
let tasks_for_join = tasks.clone();
|
let tasks_for_join = tasks.clone();
|
||||||
|
let admission_timeout = self.shutdown_admission_timeout;
|
||||||
let cleanup = self.run_joined_on_adapter_runtime(async move {
|
let cleanup = self.run_joined_on_adapter_runtime(async move {
|
||||||
tasks_for_join
|
tasks_for_join
|
||||||
.request_shutdown(&handle, &shutdown_requested, Duration::from_secs(5))
|
.request_shutdown(&handle, &shutdown_requested, admission_timeout)
|
||||||
.await?;
|
.await?;
|
||||||
tasks_for_join.confirm_shutdown_and_join(&shutdown).await
|
tasks_for_join.confirm_shutdown_and_join(&shutdown).await
|
||||||
});
|
});
|
||||||
@@ -3170,9 +3173,10 @@ where
|
|||||||
let shutdown_requested = execution.shutdown_requested.clone();
|
let shutdown_requested = execution.shutdown_requested.clone();
|
||||||
let shutdown = execution.shutdown.clone();
|
let shutdown = execution.shutdown.clone();
|
||||||
let tasks = execution.tasks.clone();
|
let tasks = execution.tasks.clone();
|
||||||
|
let admission_timeout = self.shutdown_admission_timeout;
|
||||||
let cleanup = self.run_joined_on_adapter_runtime(async move {
|
let cleanup = self.run_joined_on_adapter_runtime(async move {
|
||||||
tasks
|
tasks
|
||||||
.request_shutdown(&handle, &shutdown_requested, Duration::from_secs(5))
|
.request_shutdown(&handle, &shutdown_requested, admission_timeout)
|
||||||
.await?;
|
.await?;
|
||||||
tasks.confirm_shutdown_and_join(&shutdown).await?;
|
tasks.confirm_shutdown_and_join(&shutdown).await?;
|
||||||
handle.delete_uncommitted_uploaded_files().map_err(|error| {
|
handle.delete_uncommitted_uploaded_files().map_err(|error| {
|
||||||
@@ -4871,15 +4875,11 @@ mod tests {
|
|||||||
|
|
||||||
const SAVED_SUBJEKTIV_SYSTEM_PROMPT: &str = "Committed saved Subject Worker system prompt";
|
const SAVED_SUBJEKTIV_SYSTEM_PROMPT: &str = "Committed saved Subject Worker system prompt";
|
||||||
|
|
||||||
async fn saved_subjektiv_restore_fixture(
|
fn saved_subjektiv_policy(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
materialize_head: bool,
|
worker_ref: &WorkerRef,
|
||||||
) -> WorkerExecutionRestoreRequest {
|
) -> (WorkerManifest, CreateWorkerRequest) {
|
||||||
let worker_ref = WorkerRef::new(WorkerId::now_v7());
|
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(worker_ref);
|
||||||
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
|
||||||
let aggregate_dir = root.join("workers").join(worker_ref.worker_id.to_string());
|
|
||||||
let session_store = WorkerSessionStore::new(aggregate_dir.join("session")).unwrap();
|
|
||||||
let store = WorkerAggregateStore::new(&aggregate_dir, &worker_name).unwrap();
|
|
||||||
let mut saved = WorkerManifest::from_toml(&format!(
|
let mut saved = WorkerManifest::from_toml(&format!(
|
||||||
r#"
|
r#"
|
||||||
[worker]
|
[worker]
|
||||||
@@ -4920,6 +4920,19 @@ mod tests {
|
|||||||
language: "English".to_string(),
|
language: "English".to_string(),
|
||||||
});
|
});
|
||||||
bind_workspace_memory_settings(&mut saved, &request).unwrap();
|
bind_workspace_memory_settings(&mut saved, &request).unwrap();
|
||||||
|
(saved, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn saved_subjektiv_restore_fixture(
|
||||||
|
root: &Path,
|
||||||
|
materialize_head: bool,
|
||||||
|
) -> WorkerExecutionRestoreRequest {
|
||||||
|
let worker_ref = WorkerRef::new(WorkerId::now_v7());
|
||||||
|
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
||||||
|
let aggregate_dir = root.join("workers").join(worker_ref.worker_id.to_string());
|
||||||
|
let session_store = WorkerSessionStore::new(aggregate_dir.join("session")).unwrap();
|
||||||
|
let store = WorkerAggregateStore::new(&aggregate_dir, &worker_name).unwrap();
|
||||||
|
let (saved, request) = saved_subjektiv_policy(root, &worker_ref);
|
||||||
let client = MockClient::new(Vec::new());
|
let client = MockClient::new(Vec::new());
|
||||||
let mut engine =
|
let mut engine =
|
||||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
||||||
@@ -5785,7 +5798,12 @@ mod tests {
|
|||||||
fn duplicate_connect_timeout_retains_candidate_waiter_and_no_false_stop() {
|
fn duplicate_connect_timeout_retains_candidate_waiter_and_no_false_stop() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let candidate_root = tempfile::tempdir().unwrap();
|
let candidate_root = tempfile::tempdir().unwrap();
|
||||||
let (backend, request) = profile_stop_fixture(root.path());
|
let (mut backend, request) = profile_stop_fixture(root.path());
|
||||||
|
// Only admission's deadline is shortened. The real Controller, receipt
|
||||||
|
// correlation, retained candidate and completion/join barriers stay live.
|
||||||
|
// The candidate send is gated below, so timeout is not a scheduler race.
|
||||||
|
let normal_admission_timeout = backend.shutdown_admission_timeout;
|
||||||
|
backend.shutdown_admission_timeout = Duration::from_millis(100);
|
||||||
let primary = backend
|
let primary = backend
|
||||||
.workers
|
.workers
|
||||||
.lock()
|
.lock()
|
||||||
@@ -5864,6 +5882,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.contains_key(&request.worker_ref)
|
.contains_key(&request.worker_ref)
|
||||||
);
|
);
|
||||||
|
backend.shutdown_admission_timeout = normal_admission_timeout;
|
||||||
release.send(()).unwrap();
|
release.send(()).unwrap();
|
||||||
let rejected = backend.stop_worker(&request.worker_ref);
|
let rejected = backend.stop_worker(&request.worker_ref);
|
||||||
assert!(!rejected.is_accepted());
|
assert!(!rejected.is_accepted());
|
||||||
@@ -5878,21 +5897,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial_test::serial(worker_allocation)]
|
|
||||||
fn pending_restore_join_failure_never_becomes_absence_on_stop_retry() {
|
fn pending_restore_join_failure_never_becomes_absence_on_stop_retry() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let runtime_store = root.path().join("runtime");
|
|
||||||
let backend = WorkerRuntimeExecutionBackend::new(
|
let backend = WorkerRuntimeExecutionBackend::new(
|
||||||
ProfileRuntimeWorkerFactory::new(root.path())
|
ProfileRuntimeWorkerFactory::new(root.path())
|
||||||
.with_runtime_store_dir(&runtime_store)
|
.with_runtime_store_dir(root.path().join("runtime")),
|
||||||
.with_runtime_id("runtime-subjektiv-test"),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = backend
|
// No Controller was ever published: this is the pending-task ownership
|
||||||
.run_on_adapter_runtime(async move {
|
// rule, not saved-Session restore. Do not boot a Worker to manufacture it.
|
||||||
Ok(saved_subjektiv_restore_fixture(&runtime_store, true).await)
|
let request = direct_restore_request(WorkerRef::new(WorkerId::now_v7()));
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
let failed = backend
|
let failed = backend
|
||||||
.spawn_on_adapter_runtime(async {
|
.spawn_on_adapter_runtime(async {
|
||||||
panic!("factory scope failed before publishing result")
|
panic!("factory scope failed before publishing result")
|
||||||
@@ -6295,6 +6309,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saved_manifest_policy_rejects_mismatched_or_missing_trusted_settings() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let worker_ref = WorkerRef::new(WorkerId::now_v7());
|
||||||
|
let (saved, request) = saved_subjektiv_policy(root.path(), &worker_ref);
|
||||||
|
let mut metadata = WorkerMetadata::new(&saved.worker.name, None);
|
||||||
|
metadata.resolved_manifest_snapshot =
|
||||||
|
Some(manifest::write_persisted_worker_manifest_snapshot(&saved).unwrap());
|
||||||
|
let (restored, _) =
|
||||||
|
ProfileRuntimeWorkerFactory::manifest_for_restore(&metadata, &request).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
manifest::write_persisted_worker_manifest_snapshot(&restored).unwrap(),
|
||||||
|
metadata.resolved_manifest_snapshot.clone().unwrap()
|
||||||
|
);
|
||||||
|
let mut wrong_settings = request.clone();
|
||||||
|
wrong_settings.memory_settings.as_mut().unwrap().language = "different language".into();
|
||||||
|
let mut detached = request.clone();
|
||||||
|
detached.subjektiv_attached = false;
|
||||||
|
let mut missing_settings = request.clone();
|
||||||
|
missing_settings.memory_settings = None;
|
||||||
|
for (invalid, expected) in [
|
||||||
|
(wrong_settings, "subjektiv settings snapshot mismatch"),
|
||||||
|
(detached, "unauthorized subjektiv attachment"),
|
||||||
|
(missing_settings, "missing its trusted settings snapshot"),
|
||||||
|
] {
|
||||||
|
let error = ProfileRuntimeWorkerFactory::manifest_for_restore(&metadata, &invalid)
|
||||||
|
.err()
|
||||||
|
.expect("invalid saved policy must be rejected");
|
||||||
|
assert!(error.contains(expected), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration: real Worker startup writes the saved Manifest/Session, then
|
||||||
|
// both factory entry points reject it before allocating any live resources.
|
||||||
|
// The local input matrix belongs to saved_manifest_policy_* above.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial(worker_allocation)]
|
#[serial_test::serial(worker_allocation)]
|
||||||
async fn profile_factory_saved_manifest_mismatch_rejected_before_live_work() {
|
async fn profile_factory_saved_manifest_mismatch_rejected_before_live_work() {
|
||||||
@@ -6312,28 +6361,27 @@ mod tests {
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.language = "different language".into();
|
.language = "different language".into();
|
||||||
let mut detached = request.clone();
|
let error = factory
|
||||||
detached.request.subjektiv_attached = false;
|
.preflight_restore(&wrong_settings)
|
||||||
let mut missing_settings = request.clone();
|
.await
|
||||||
missing_settings.request.memory_settings = None;
|
.unwrap_err();
|
||||||
for (invalid, expected) in [
|
assert!(
|
||||||
(wrong_settings, "subjektiv settings snapshot mismatch"),
|
error.contains("subjektiv settings snapshot mismatch"),
|
||||||
(detached, "unauthorized subjektiv attachment"),
|
"{error}"
|
||||||
(missing_settings, "missing its trusted settings snapshot"),
|
);
|
||||||
] {
|
let error = match factory.restore_controller(wrong_settings).await {
|
||||||
let error = factory.preflight_restore(&invalid).await.unwrap_err();
|
Ok(controller) => {
|
||||||
assert!(error.contains(expected), "{error}");
|
shutdown_profile_controller(controller).await;
|
||||||
let error = match factory.restore_controller(invalid).await {
|
panic!("invalid restore started a Controller")
|
||||||
Ok(controller) => {
|
}
|
||||||
shutdown_profile_controller(controller).await;
|
Err(error) => error,
|
||||||
panic!("invalid restore started a Controller")
|
};
|
||||||
}
|
assert!(
|
||||||
Err(error) => error,
|
error.contains("subjektiv settings snapshot mismatch"),
|
||||||
};
|
"{error}"
|
||||||
assert!(error.contains(expected), "{error}");
|
);
|
||||||
assert_eq!(persisted_files(&runtime_store), before);
|
assert_eq!(persisted_files(&runtime_store), before);
|
||||||
assert!(factory.observation_hub.workers.lock().unwrap().is_empty());
|
assert!(factory.observation_hub.workers.lock().unwrap().is_empty());
|
||||||
}
|
|
||||||
let run_dir = factory
|
let run_dir = factory
|
||||||
.worker_aggregate_dir(&request.worker_ref)
|
.worker_aggregate_dir(&request.worker_ref)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -6770,7 +6818,7 @@ mod tests {
|
|||||||
assert!(scope.tasks.lock().unwrap().is_empty());
|
assert!(scope.tasks.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test(start_paused = true)]
|
||||||
async fn execution_scope_timeout_retains_exact_task_until_completion_is_proven() {
|
async fn execution_scope_timeout_retains_exact_task_until_completion_is_proven() {
|
||||||
let release = Arc::new(tokio::sync::Notify::new());
|
let release = Arc::new(tokio::sync::Notify::new());
|
||||||
let task_release = Arc::clone(&release);
|
let task_release = Arc::clone(&release);
|
||||||
@@ -7075,29 +7123,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DelayedFactory {
|
struct PendingCreateFactory {
|
||||||
completed: Arc<AtomicBool>,
|
started: mpsc::Sender<()>,
|
||||||
delay: Duration,
|
dropped: mpsc::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FactoryFutureDrop(mpsc::Sender<()>);
|
||||||
|
|
||||||
|
impl Drop for FactoryFutureDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.0.send(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RuntimeWorkerFactory for DelayedFactory {
|
impl RuntimeWorkerFactory for PendingCreateFactory {
|
||||||
async fn spawn_controller(
|
async fn spawn_controller(
|
||||||
&self,
|
&self,
|
||||||
_request: WorkerExecutionSpawnRequest,
|
_request: WorkerExecutionSpawnRequest,
|
||||||
) -> Result<RuntimeWorkerController, String> {
|
) -> Result<RuntimeWorkerController, String> {
|
||||||
tokio::time::sleep(self.delay).await;
|
let _drop = FactoryFutureDrop(self.dropped.clone());
|
||||||
self.completed.store(true, Ordering::SeqCst);
|
self.started.send(()).unwrap();
|
||||||
Err("delayed factory completed".to_string())
|
std::future::pending().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn restore_controller(
|
async fn restore_controller(
|
||||||
&self,
|
&self,
|
||||||
_request: WorkerExecutionRestoreRequest,
|
_request: WorkerExecutionRestoreRequest,
|
||||||
) -> Result<RuntimeWorkerController, String> {
|
) -> Result<RuntimeWorkerController, String> {
|
||||||
tokio::time::sleep(self.delay).await;
|
panic!("create timeout must not restore a Worker")
|
||||||
self.completed.store(true, Ordering::SeqCst);
|
|
||||||
Err("delayed factory completed".to_string())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7105,11 +7159,12 @@ mod tests {
|
|||||||
fn create_timeout_cancels_factory_and_removes_persisted_worker() {
|
fn create_timeout_cancels_factory_and_removes_persisted_worker() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let runtime_store_dir = root.path().join("runtime");
|
let runtime_store_dir = root.path().join("runtime");
|
||||||
let completed = Arc::new(AtomicBool::new(false));
|
let (started_tx, started) = mpsc::channel();
|
||||||
|
let (dropped_tx, dropped) = mpsc::channel();
|
||||||
let backend = Arc::new(
|
let backend = Arc::new(
|
||||||
WorkerRuntimeExecutionBackend::new(DelayedFactory {
|
WorkerRuntimeExecutionBackend::new(PendingCreateFactory {
|
||||||
completed: completed.clone(),
|
started: started_tx,
|
||||||
delay: Duration::from_millis(200),
|
dropped: dropped_tx,
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_spawn_restore_timeout(Duration::from_millis(20)),
|
.with_spawn_restore_timeout(Duration::from_millis(20)),
|
||||||
@@ -7128,7 +7183,9 @@ mod tests {
|
|||||||
let worker_id = request.worker_id;
|
let worker_id = request.worker_id;
|
||||||
let create_runtime = runtime.clone();
|
let create_runtime = runtime.clone();
|
||||||
let create = std::thread::spawn(move || create_runtime.create_worker(request));
|
let create = std::thread::spawn(move || create_runtime.create_worker(request));
|
||||||
std::thread::sleep(Duration::from_millis(5));
|
started
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("factory must start before observing concurrent deletion");
|
||||||
|
|
||||||
let delete_error = runtime
|
let delete_error = runtime
|
||||||
.delete_worker(&crate::identity::WorkerRef::new(worker_id))
|
.delete_worker(&crate::identity::WorkerRef::new(worker_id))
|
||||||
@@ -7140,11 +7197,9 @@ mod tests {
|
|||||||
crate::error::RuntimeError::WorkerNotFound { worker_id: missing } if missing == worker_id
|
crate::error::RuntimeError::WorkerNotFound { worker_id: missing } if missing == worker_id
|
||||||
));
|
));
|
||||||
assert!(error.to_string().contains("was cancelled"));
|
assert!(error.to_string().contains("was cancelled"));
|
||||||
std::thread::sleep(Duration::from_millis(250));
|
dropped
|
||||||
assert!(
|
.try_recv()
|
||||||
!completed.load(Ordering::SeqCst),
|
.expect("cancelled factory future must be dropped before create returns");
|
||||||
"timed out factory future must not resume after create returns"
|
|
||||||
);
|
|
||||||
assert!(runtime.list_workers().unwrap().is_empty());
|
assert!(runtime.list_workers().unwrap().is_empty());
|
||||||
assert!(backend.workers.lock().unwrap().is_empty());
|
assert!(backend.workers.lock().unwrap().is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = Arc::new(
|
let store = Arc::new(
|
||||||
SqliteWorkspaceStore::in_memory_for_test()
|
SqliteWorkspaceStore::in_memory_fixture()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_db_executor(executor),
|
.with_db_executor(executor),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -237,6 +237,25 @@ impl SqliteWorkspaceAuthority {
|
|||||||
let database_path = database_path.into();
|
let database_path = database_path.into();
|
||||||
let workspace_id = workspace_id.into();
|
let workspace_id = workspace_id.into();
|
||||||
let store = SqliteWorkspaceStore::open(&database_path)?;
|
let store = SqliteWorkspaceStore::open(&database_path)?;
|
||||||
|
Self::from_store(database_path, workspace_id, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal API fixtures retain real Ticket/MR stores, but the Workspace schema
|
||||||
|
// was verified when their pristine DB was prepared. Startup still uses new().
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_fixture_store(
|
||||||
|
database_path: PathBuf,
|
||||||
|
workspace_id: String,
|
||||||
|
store: SqliteWorkspaceStore,
|
||||||
|
) -> Result<Self> {
|
||||||
|
Self::from_store(database_path, workspace_id, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_store(
|
||||||
|
database_path: PathBuf,
|
||||||
|
workspace_id: String,
|
||||||
|
store: SqliteWorkspaceStore,
|
||||||
|
) -> Result<Self> {
|
||||||
let merge_request_source = Arc::new(AuthorityMergeRequestSource {
|
let merge_request_source = Arc::new(AuthorityMergeRequestSource {
|
||||||
store: store.clone(),
|
store: store.clone(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn open_store() -> SqliteWorkspaceStore {
|
async fn open_store() -> SqliteWorkspaceStore {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store.upsert_workspace(&workspace()).await.unwrap();
|
store.upsert_workspace(&workspace()).await.unwrap();
|
||||||
store
|
store
|
||||||
}
|
}
|
||||||
@@ -1321,26 +1321,28 @@ mod tests {
|
|||||||
assert!(main_config_has_schema_assertion(DEFAULT_MAIN_CONFIG_SOURCE));
|
assert!(main_config_has_schema_assertion(DEFAULT_MAIN_CONFIG_SOURCE));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn candidate_normalizes_main_and_formats_changed_decodal_sources() {
|
fn candidate_normalizes_main_and_formats_changed_decodal_sources() {
|
||||||
let store = open_store().await;
|
let current = initial_state().unwrap();
|
||||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
let changes = [
|
||||||
let candidate = store
|
update_main(¤t, "{}"),
|
||||||
.evaluate_workspace_config_candidate(
|
ConfigTreeChange::Create {
|
||||||
"w-config",
|
path: path("module.dcdl"),
|
||||||
&commit_request(
|
content_type: ConfigContentType::Decodal,
|
||||||
¤t,
|
content: "{}".into(),
|
||||||
vec![
|
},
|
||||||
update_main(¤t, "{}"),
|
];
|
||||||
ConfigTreeChange::Create {
|
let identity = (
|
||||||
path: path("module.dcdl"),
|
current.contract.fingerprint.clone(),
|
||||||
content_type: ConfigContentType::Decodal,
|
current.projection_digest.clone(),
|
||||||
content: "{}".into(),
|
);
|
||||||
},
|
let candidate = evaluate_candidate(
|
||||||
],
|
current,
|
||||||
),
|
identity,
|
||||||
)
|
&changes,
|
||||||
.unwrap();
|
WorkspaceConfigSchemaBundle::empty(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
candidate
|
candidate
|
||||||
@@ -1414,34 +1416,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn required_main_entrypoint_cannot_be_deleted_or_renamed() {
|
fn required_main_entrypoint_cannot_be_deleted_or_renamed() {
|
||||||
let store = open_store().await;
|
|
||||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
|
||||||
let main = current.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap();
|
|
||||||
for change in [
|
for change in [
|
||||||
ConfigTreeChange::Delete {
|
ConfigTreeChange::Delete {
|
||||||
path: path(MAIN_CONFIG_ENTRYPOINT),
|
path: path(MAIN_CONFIG_ENTRYPOINT),
|
||||||
expected_digest: main.content_digest.clone(),
|
expected_digest: "sha256:current".into(),
|
||||||
},
|
},
|
||||||
ConfigTreeChange::Rename {
|
ConfigTreeChange::Rename {
|
||||||
from: path(MAIN_CONFIG_ENTRYPOINT),
|
from: path(MAIN_CONFIG_ENTRYPOINT),
|
||||||
to: path("other.dcdl"),
|
to: path("other.dcdl"),
|
||||||
expected_digest: main.content_digest.clone(),
|
expected_digest: "sha256:current".into(),
|
||||||
|
},
|
||||||
|
ConfigTreeChange::Rename {
|
||||||
|
from: path("other.dcdl"),
|
||||||
|
to: path(MAIN_CONFIG_ENTRYPOINT),
|
||||||
|
expected_digest: "sha256:current".into(),
|
||||||
},
|
},
|
||||||
] {
|
] {
|
||||||
let error = store
|
let error = reject_main_entrypoint_mutation(&[change]).unwrap_err();
|
||||||
.evaluate_workspace_config_candidate(
|
|
||||||
"w-config",
|
|
||||||
&commit_request(¤t, vec![change]),
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(error.to_string().contains("cannot be"));
|
assert!(error.to_string().contains("cannot be"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entrypoint_request_requires_exactly_the_server_owned_main() {
|
||||||
|
for entrypoints in [
|
||||||
|
vec![],
|
||||||
|
vec![path("other.dcdl")],
|
||||||
|
vec![path(MAIN_CONFIG_ENTRYPOINT), path("other.dcdl")],
|
||||||
|
] {
|
||||||
|
let error = validate_entrypoint_request(&entrypoints).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("must be exactly [main.dcdl]"));
|
||||||
|
}
|
||||||
|
validate_entrypoint_request(&[path(MAIN_CONFIG_ENTRYPOINT)]).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn browser_cannot_replace_server_owned_entrypoint_contract() {
|
async fn candidate_store_rejects_entrypoint_override_and_main_deletion_without_mutation() {
|
||||||
let store = open_store().await;
|
let store = open_store().await;
|
||||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||||
let mut request = commit_request(¤t, Vec::new());
|
let mut request = commit_request(¤t, Vec::new());
|
||||||
@@ -1450,11 +1462,33 @@ mod tests {
|
|||||||
.evaluate_workspace_config_candidate("w-config", &request)
|
.evaluate_workspace_config_candidate("w-config", &request)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("must be exactly [main.dcdl]"));
|
assert!(error.to_string().contains("must be exactly [main.dcdl]"));
|
||||||
|
let main = current.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap();
|
||||||
|
let error = store
|
||||||
|
.evaluate_workspace_config_candidate(
|
||||||
|
"w-config",
|
||||||
|
&commit_request(
|
||||||
|
¤t,
|
||||||
|
vec![ConfigTreeChange::Delete {
|
||||||
|
path: path(MAIN_CONFIG_ENTRYPOINT),
|
||||||
|
expected_digest: main.content_digest.clone(),
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("cannot be deleted"));
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.load_workspace_config("w-config")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.snapshot,
|
||||||
|
current.snapshot
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invalid_candidate_is_never_persisted() {
|
async fn invalid_candidate_is_never_persisted() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store.upsert_workspace(&workspace()).await.unwrap();
|
store.upsert_workspace(&workspace()).await.unwrap();
|
||||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||||
let main = current.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap();
|
let main = current.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap();
|
||||||
@@ -1478,7 +1512,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn valid_candidate_commits_snapshot_content_and_provenance_atomically() {
|
async fn valid_candidate_commits_snapshot_content_and_provenance_atomically() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store.upsert_workspace(&workspace()).await.unwrap();
|
store.upsert_workspace(&workspace()).await.unwrap();
|
||||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||||
let committed = store
|
let committed = store
|
||||||
@@ -1496,7 +1530,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stale_cas_cannot_overwrite_newer_tree() {
|
async fn stale_cas_cannot_overwrite_newer_tree() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store.upsert_workspace(&workspace()).await.unwrap();
|
store.upsert_workspace(&workspace()).await.unwrap();
|
||||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||||
let request = commit_request(¤t, vec![update_main(¤t, "{ answer = 42; }")]);
|
let request = commit_request(¤t, vec![update_main(¤t, "{ answer = 42; }")]);
|
||||||
@@ -1514,7 +1548,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn committed_content_remains_retrievable_after_later_commit() {
|
async fn committed_content_remains_retrievable_after_later_commit() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store.upsert_workspace(&workspace()).await.unwrap();
|
store.upsert_workspace(&workspace()).await.unwrap();
|
||||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||||
let first = store
|
let first = store
|
||||||
|
|||||||
@@ -6681,7 +6681,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = Arc::new(
|
let store = Arc::new(
|
||||||
SqliteWorkspaceStore::in_memory_for_test()
|
SqliteWorkspaceStore::in_memory_fixture()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_db_executor(executor.clone()),
|
.with_db_executor(executor.clone()),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1404,7 +1404,7 @@ mod tests {
|
|||||||
WorkerId::from_legacy_u64(1)
|
WorkerId::from_legacy_u64(1)
|
||||||
}
|
}
|
||||||
fn setup() -> SqliteWorkspaceStore {
|
fn setup() -> SqliteWorkspaceStore {
|
||||||
let s = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let s = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
s.with_conn(|c| {
|
s.with_conn(|c| {
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) \
|
"INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) \
|
||||||
|
|||||||
@@ -3635,6 +3635,38 @@ fn embedded_runtime_request_audience(config: &ServerConfig) -> crate::Result<Str
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum WorkspaceDatabasePreparation {
|
||||||
|
Startup,
|
||||||
|
#[cfg(test)]
|
||||||
|
PreparedFixture,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceDatabasePreparation {
|
||||||
|
fn open_store(self, path: &Path) -> Result<crate::SqliteWorkspaceStore> {
|
||||||
|
match self {
|
||||||
|
Self::Startup => crate::SqliteWorkspaceStore::open(path),
|
||||||
|
#[cfg(test)]
|
||||||
|
Self::PreparedFixture => crate::SqliteWorkspaceStore::connect_prepared_fixture(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_authority(
|
||||||
|
self,
|
||||||
|
path: PathBuf,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<SqliteWorkspaceAuthority> {
|
||||||
|
match self {
|
||||||
|
Self::Startup => SqliteWorkspaceAuthority::new(path, workspace_id),
|
||||||
|
#[cfg(test)]
|
||||||
|
Self::PreparedFixture => {
|
||||||
|
let store = self.open_store(&path)?;
|
||||||
|
SqliteWorkspaceAuthority::from_fixture_store(path, workspace_id, store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl WorkspaceApi {
|
impl WorkspaceApi {
|
||||||
/// Trusted Workspace-scoped Drive access for backend adapters. This is not
|
/// Trusted Workspace-scoped Drive access for backend adapters. This is not
|
||||||
/// model authorization; callers must authorize requests before using it.
|
/// model authorization; callers must authorize requests before using it.
|
||||||
@@ -3856,7 +3888,57 @@ impl WorkspaceApi {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
async fn new_api_fixture(
|
||||||
|
config: ServerConfig,
|
||||||
|
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
// Only a newly copied pristine fixture skips repeated Workspace schema
|
||||||
|
// checks. Restart/recovery fixtures retain the complete startup path.
|
||||||
|
let preparation = if config.database_path.exists() {
|
||||||
|
WorkspaceDatabasePreparation::Startup
|
||||||
|
} else {
|
||||||
|
WorkspaceDatabasePreparation::PreparedFixture
|
||||||
|
};
|
||||||
|
let store = Arc::new(crate::SqliteWorkspaceStore::open_api_fixture(
|
||||||
|
&config.database_path,
|
||||||
|
)?);
|
||||||
|
seed_test_registered_workspace(store.as_ref(), &config).await?;
|
||||||
|
Self::new_with_database_preparation(
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
execution_backend,
|
||||||
|
BackendResourceBroker::default(),
|
||||||
|
HostExecutors::default(),
|
||||||
|
None,
|
||||||
|
preparation,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn new_with_execution_backend_and_broker(
|
async fn new_with_execution_backend_and_broker(
|
||||||
|
config: ServerConfig,
|
||||||
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
|
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
||||||
|
resource_broker: BackendResourceBroker,
|
||||||
|
host_executors: HostExecutors,
|
||||||
|
worker_remove_dispatcher: Option<
|
||||||
|
Arc<crate::worker_source::EmbeddedServerWorkerMutationDispatcher>,
|
||||||
|
>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
Self::new_with_database_preparation(
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
execution_backend,
|
||||||
|
resource_broker,
|
||||||
|
host_executors,
|
||||||
|
worker_remove_dispatcher,
|
||||||
|
WorkspaceDatabasePreparation::Startup,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn new_with_database_preparation(
|
||||||
mut config: ServerConfig,
|
mut config: ServerConfig,
|
||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
||||||
@@ -3865,6 +3947,7 @@ impl WorkspaceApi {
|
|||||||
worker_remove_dispatcher: Option<
|
worker_remove_dispatcher: Option<
|
||||||
Arc<crate::worker_source::EmbeddedServerWorkerMutationDispatcher>,
|
Arc<crate::worker_source::EmbeddedServerWorkerMutationDispatcher>,
|
||||||
>,
|
>,
|
||||||
|
database_preparation: WorkspaceDatabasePreparation,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
if store.get_workspace(&config.workspace_id).await?.is_none() {
|
if store.get_workspace(&config.workspace_id).await?.is_none() {
|
||||||
return Err(crate::Error::Config(format!(
|
return Err(crate::Error::Config(format!(
|
||||||
@@ -3904,7 +3987,8 @@ impl WorkspaceApi {
|
|||||||
let (config_store, repository_secrets) = executor
|
let (config_store, repository_secrets) = executor
|
||||||
.run(move || {
|
.run(move || {
|
||||||
let config_store = Arc::new(
|
let config_store = Arc::new(
|
||||||
crate::SqliteWorkspaceStore::open(database_path.clone())?
|
database_preparation
|
||||||
|
.open_store(&database_path)?
|
||||||
.with_db_executor(config_executor),
|
.with_db_executor(config_executor),
|
||||||
);
|
);
|
||||||
let repository_secrets = Arc::new(RepositorySecretService::open(
|
let repository_secrets = Arc::new(RepositorySecretService::open(
|
||||||
@@ -4005,7 +4089,7 @@ impl WorkspaceApi {
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
Error::Store(format!("failed to register subjektiv storage: {error}"))
|
Error::Store(format!("failed to register subjektiv storage: {error}"))
|
||||||
})?;
|
})?;
|
||||||
let authority = SqliteWorkspaceAuthority::new(database_path, workspace_id)?;
|
let authority = database_preparation.open_authority(database_path, workspace_id)?;
|
||||||
Ok::<_, Error>((feature_storage, registration, authority))
|
Ok::<_, Error>((feature_storage, registration, authority))
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -28226,6 +28310,29 @@ async fn set_worker_retention(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cleanup_workdir_decision(
|
||||||
|
file_status: CleanupWorkdirFileStatus,
|
||||||
|
cleanliness: CleanupWorkdirCleanliness,
|
||||||
|
running_linked: bool,
|
||||||
|
pinned_linked: bool,
|
||||||
|
) -> (CleanupTargetKind, Option<String>) {
|
||||||
|
let action = if cleanup_workdir_file_status_is_record_only(file_status) {
|
||||||
|
CleanupTargetKind::WorkdirRecordDelete
|
||||||
|
} else if !matches!(cleanliness, CleanupWorkdirCleanliness::Dirty) {
|
||||||
|
CleanupTargetKind::WorkdirCleanCleanup
|
||||||
|
} else {
|
||||||
|
CleanupTargetKind::WorkdirDirtyDiscard
|
||||||
|
};
|
||||||
|
let blocking_reason = if running_linked {
|
||||||
|
Some("workdir is linked to a running Worker".to_string())
|
||||||
|
} else if pinned_linked {
|
||||||
|
Some("workdir is linked to a pinned Worker/history".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
(action, blocking_reason)
|
||||||
|
}
|
||||||
|
|
||||||
fn build_runtime_cleanup_plan(
|
fn build_runtime_cleanup_plan(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -28371,20 +28478,8 @@ fn build_runtime_cleanup_plan(
|
|||||||
.get(record.workdir_id.as_str())
|
.get(record.workdir_id.as_str())
|
||||||
.map(|summary| cleanup_workdir_cleanliness_from_runtime(summary.cleanliness.as_deref()))
|
.map(|summary| cleanup_workdir_cleanliness_from_runtime(summary.cleanliness.as_deref()))
|
||||||
.unwrap_or_else(|| cleanup_workdir_cleanliness_from_registry(&record.cleanliness));
|
.unwrap_or_else(|| cleanup_workdir_cleanliness_from_registry(&record.cleanliness));
|
||||||
let action = if cleanup_workdir_file_status_is_record_only(file_status) {
|
let (action, blocking_reason) =
|
||||||
CleanupTargetKind::WorkdirRecordDelete
|
cleanup_workdir_decision(file_status, cleanliness, running_linked, pinned_linked);
|
||||||
} else if !matches!(cleanliness, CleanupWorkdirCleanliness::Dirty) {
|
|
||||||
CleanupTargetKind::WorkdirCleanCleanup
|
|
||||||
} else {
|
|
||||||
CleanupTargetKind::WorkdirDirtyDiscard
|
|
||||||
};
|
|
||||||
let blocking_reason = if running_linked {
|
|
||||||
Some("workdir is linked to a running Worker".to_string())
|
|
||||||
} else if pinned_linked {
|
|
||||||
Some("workdir is linked to a pinned Worker/history".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let repository_key = api
|
let repository_key = api
|
||||||
.store
|
.store
|
||||||
.get_repository(&api.config.workspace_id, repository_id)?
|
.get_repository(&api.config.workspace_id, repository_id)?
|
||||||
@@ -47647,132 +47742,56 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ticket_checker_fake_model_regressions_preserve_classification_boundaries() {
|
async fn ticket_checker_routes_model_advisory_after_authenticated_save() {
|
||||||
struct FakeTicketCheckerModel;
|
let case = ticket_item_checker::test_support::cases()
|
||||||
|
.into_iter()
|
||||||
impl FakeTicketCheckerModel {
|
.find(|case| case.name == "writer-scope-positive")
|
||||||
fn respond(request: &BackendJobRequest) -> Value {
|
.unwrap();
|
||||||
let input = ticket_item_checker::parse_input(&request.input).unwrap();
|
|
||||||
assert!(request.instruction.contains("writer_scope"));
|
|
||||||
assert!(request.instruction.contains("internal_inconsistency"));
|
|
||||||
assert!(request.instruction.contains("ambiguous_boundary"));
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.instruction
|
|
||||||
.contains("Production deployment requires approval")
|
|
||||||
);
|
|
||||||
assert!(request.instruction.contains("background, quotations"));
|
|
||||||
if input
|
|
||||||
.body
|
|
||||||
.contains("This time I only create the Ticket; I will not implement it.")
|
|
||||||
&& !input.body.starts_with("Background bug example:")
|
|
||||||
{
|
|
||||||
serde_json::json!({"findings": [{
|
|
||||||
"category": "writer_scope",
|
|
||||||
"quote": "I will not implement it",
|
|
||||||
"reason": "The active requirement records the writer's own implementation boundary.",
|
|
||||||
"suggestion": "Remove the writer-specific action boundary."
|
|
||||||
}]})
|
|
||||||
} else if input.body.contains("The rollout region is undecided.")
|
|
||||||
&& input.body.contains("The rollout region is us-east-1.")
|
|
||||||
{
|
|
||||||
serde_json::json!({"findings": [{
|
|
||||||
"category": "internal_inconsistency",
|
|
||||||
"quote": "The rollout region is undecided.",
|
|
||||||
"reason": "The same active text also settles the rollout region as us-east-1.",
|
|
||||||
"suggestion": "Choose either the unresolved or settled rollout-region statement."
|
|
||||||
}]})
|
|
||||||
} else if input.body == "Implementation stops after Ticket creation." {
|
|
||||||
serde_json::json!({"findings": [{
|
|
||||||
"category": "ambiguous_boundary",
|
|
||||||
"quote": "Implementation stops after Ticket creation.",
|
|
||||||
"reason": "The text alone does not establish whether this is a delivery constraint or the writer's current action boundary.",
|
|
||||||
"suggestion": "Confirm whether this limits the product workflow or only the current writer."
|
|
||||||
}]})
|
|
||||||
} else {
|
|
||||||
serde_json::json!({"findings": []})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Case {
|
|
||||||
name: &'static str,
|
|
||||||
body: &'static str,
|
|
||||||
advisory_quote: Option<&'static str>,
|
|
||||||
}
|
|
||||||
let cases = vec![
|
|
||||||
Case {
|
|
||||||
name: "writer-scope-positive",
|
|
||||||
body: "This time I only create the Ticket; I will not implement it.",
|
|
||||||
advisory_quote: Some("I will not implement it"),
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
name: "production-approval-negative",
|
|
||||||
body: "Production deployment requires approval.",
|
|
||||||
advisory_quote: None,
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
name: "quoted-background-negative",
|
|
||||||
body: "Background bug example: a generated Ticket once said “I will not implement it.” Do not reproduce that sentence in generated requirements.",
|
|
||||||
advisory_quote: None,
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
name: "unresolved-versus-settled-positive",
|
|
||||||
body: "The rollout region is undecided. The rollout region is us-east-1.",
|
|
||||||
advisory_quote: Some("The rollout region is undecided."),
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
name: "ambiguous-boundary-confirmation",
|
|
||||||
body: "Implementation stops after Ticket creation.",
|
|
||||||
advisory_quote: Some("Implementation stops after Ticket creation."),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (api, execution) = test_api_with_recording_backend(temp.path()).await;
|
let (api, execution) = test_api_with_recording_backend(temp.path()).await;
|
||||||
let source = spawn_ticket_check_source(&api, "ticket-check-boundary-author");
|
let source = spawn_ticket_check_source(&api, "ticket-check-boundary-author");
|
||||||
for case in cases {
|
let mut input = ticket::NewTicket::new(format!("Boundary case: {}", case.name));
|
||||||
let mut input = ticket::NewTicket::new(format!("Boundary case: {}", case.name));
|
input.body = MarkdownText::new(case.body);
|
||||||
input.body = MarkdownText::new(case.body);
|
let Json(TicketBackendOperationResult::TicketRef(reference)) =
|
||||||
let Json(TicketBackendOperationResult::TicketRef(reference)) =
|
execute_worker_ticket_test_operation(
|
||||||
execute_worker_ticket_test_operation(
|
State(api.clone()),
|
||||||
State(api.clone()),
|
AxumPath(ScopedWorkspacePath {
|
||||||
AxumPath(ScopedWorkspacePath {
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
}),
|
||||||
}),
|
ticket_check_headers(&source),
|
||||||
ticket_check_headers(&source),
|
Json(TicketBackendOperation::Create { input }),
|
||||||
Json(TicketBackendOperation::Create { input }),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
else {
|
|
||||||
panic!("{}: unexpected Ticket create result", case.name);
|
|
||||||
};
|
|
||||||
let job = ticket_check_job(&api, &reference.id);
|
|
||||||
let attempt = wait_for_ticket_check_attempt(&api, &job).await;
|
|
||||||
execution.take_inputs();
|
|
||||||
api.accept_backend_job_result(
|
|
||||||
attempt.worker.as_ref().unwrap(),
|
|
||||||
&BackendJobResultSubmission {
|
|
||||||
job_id: job.request.job_id.clone(),
|
|
||||||
attempt_id: attempt.attempt_id,
|
|
||||||
input_digest: job.request.input_digest().unwrap(),
|
|
||||||
result: FakeTicketCheckerModel::respond(&job.request),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.unwrap_or_else(|error| panic!("{}: {error}", case.name));
|
.await
|
||||||
let notifications = execution.take_inputs();
|
.unwrap()
|
||||||
let advisories = notifications
|
else {
|
||||||
.iter()
|
panic!("{}: unexpected Ticket create result", case.name);
|
||||||
.filter(|(_, content)| content.contains("[Ticket item checker advisory]"))
|
};
|
||||||
.collect::<Vec<_>>();
|
let job = ticket_check_job(&api, &reference.id);
|
||||||
match case.advisory_quote {
|
let attempt = wait_for_ticket_check_attempt(&api, &job).await;
|
||||||
Some(quote) => {
|
execution.take_inputs();
|
||||||
assert_eq!(advisories.len(), 1, "{}", case.name);
|
api.accept_backend_job_result(
|
||||||
assert!(advisories[0].1.contains(quote), "{}", case.name);
|
attempt.worker.as_ref().unwrap(),
|
||||||
}
|
&BackendJobResultSubmission {
|
||||||
None => assert!(advisories.is_empty(), "{}", case.name),
|
job_id: job.request.job_id.clone(),
|
||||||
|
attempt_id: attempt.attempt_id,
|
||||||
|
input_digest: job.request.input_digest().unwrap(),
|
||||||
|
result: ticket_item_checker::test_support::FakeTicketCheckerModel::respond(
|
||||||
|
&job.request,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|error| panic!("{}: {error}", case.name));
|
||||||
|
let notifications = execution.take_inputs();
|
||||||
|
let advisories = notifications
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, content)| content.contains("[Ticket item checker advisory]"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
match case.advisory_quote {
|
||||||
|
Some(quote) => {
|
||||||
|
assert_eq!(advisories.len(), 1, "{}", case.name);
|
||||||
|
assert!(advisories[0].1.contains(quote), "{}", case.name);
|
||||||
}
|
}
|
||||||
|
None => assert!(advisories.is_empty(), "{}", case.name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56591,16 +56610,56 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_startup_revalidates_workspace_schema_after_store_was_opened() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let config = test_server_config(temp.path());
|
||||||
|
let store =
|
||||||
|
Arc::new(SqliteWorkspaceStore::open_api_fixture(&config.database_path).unwrap());
|
||||||
|
seed_test_registered_workspace(store.as_ref(), &config)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Do not hold a live API/Runtime while testing startup of the same store:
|
||||||
|
// its exclusive Runtime store lease would reject before schema validation.
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute_batch("DROP TRIGGER ticket_assignment_ticket_parent_tombstone;")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let error = WorkspaceApi::new_for_test(config.clone(), store)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.expect("API startup must revalidate an already opened store");
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("ticket_assignment_ticket_parent_tombstone"),
|
||||||
|
"expected missing-trigger refusal from API startup, got {error:?}"
|
||||||
|
);
|
||||||
|
let error = WorkspaceApi::new_api_fixture(
|
||||||
|
config,
|
||||||
|
Arc::new(DeterministicExecutionBackend::default()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.expect("existing fixture DBs must use production startup");
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("ticket_assignment_ticket_parent_tombstone"),
|
||||||
|
"expected missing-trigger refusal reopening fixture, got {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async fn test_api_with_recording_backend(
|
async fn test_api_with_recording_backend(
|
||||||
workspace_root: impl Into<PathBuf>,
|
workspace_root: impl Into<PathBuf>,
|
||||||
) -> (WorkspaceApi, Arc<DeterministicExecutionBackend>) {
|
) -> (WorkspaceApi, Arc<DeterministicExecutionBackend>) {
|
||||||
let config = test_server_config(workspace_root);
|
let config = test_server_config(workspace_root);
|
||||||
let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap();
|
|
||||||
let execution = Arc::new(DeterministicExecutionBackend::default());
|
let execution = Arc::new(DeterministicExecutionBackend::default());
|
||||||
let api =
|
let api = WorkspaceApi::new_api_fixture(config, execution.clone())
|
||||||
WorkspaceApi::new_with_execution_backend(config, Arc::new(store), execution.clone())
|
.await
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
(api, execution)
|
(api, execution)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56619,14 +56678,9 @@ mod tests {
|
|||||||
config.repositories[0].source = source;
|
config.repositories[0].source = source;
|
||||||
config.repositories[0].path = None;
|
config.repositories[0].path = None;
|
||||||
config.repositories[0].default_selector = Some("develop".to_string());
|
config.repositories[0].default_selector = Some("develop".to_string());
|
||||||
let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap();
|
WorkspaceApi::new_api_fixture(config, Arc::new(DeterministicExecutionBackend::default()))
|
||||||
WorkspaceApi::new_with_execution_backend(
|
.await
|
||||||
config,
|
.unwrap()
|
||||||
Arc::new(store),
|
|
||||||
Arc::new(DeterministicExecutionBackend::default()),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn test_api_with_docs_repository(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
|
async fn test_api_with_docs_repository(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
|
||||||
@@ -56648,14 +56702,9 @@ mod tests {
|
|||||||
docs.path = Some(docs_root);
|
docs.path = Some(docs_root);
|
||||||
docs.default_selector = Some("develop".to_string());
|
docs.default_selector = Some("develop".to_string());
|
||||||
config.repositories.push(docs);
|
config.repositories.push(docs);
|
||||||
let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap();
|
WorkspaceApi::new_api_fixture(config, Arc::new(DeterministicExecutionBackend::default()))
|
||||||
WorkspaceApi::new_with_execution_backend(
|
.await
|
||||||
config,
|
.unwrap()
|
||||||
Arc::new(store),
|
|
||||||
Arc::new(DeterministicExecutionBackend::default()),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_owner_actor() -> RequestActor {
|
fn test_owner_actor() -> RequestActor {
|
||||||
@@ -59899,22 +59948,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn cleanup_plan_offers_normal_revalidation_for_prior_failure_and_unknown() {
|
fn cleanup_plan_offers_normal_revalidation_for_prior_failure_and_unknown() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
for state in ["pending", "unknown", "corrupted"] {
|
||||||
init_clean_git_workspace(workspace.path());
|
let (action, blocking_reason) = cleanup_workdir_decision(
|
||||||
let api = test_api(workspace.path()).await;
|
cleanup_workdir_file_status_from_registry(state),
|
||||||
for (id, state) in [
|
cleanup_workdir_cleanliness_from_registry("unknown"),
|
||||||
("pending-retry", "pending"),
|
false,
|
||||||
("unknown-retry", "unknown"),
|
false,
|
||||||
("corrupt-retry", "corrupted"),
|
);
|
||||||
] {
|
assert_eq!(action, CleanupTargetKind::WorkdirCleanCleanup, "{state}");
|
||||||
seed_cleanup_workdir(&api, id, state, "unknown");
|
assert!(blocking_reason.is_none(), "{state}");
|
||||||
}
|
|
||||||
let plan = build_runtime_cleanup_plan(&api, "runtime-test").unwrap();
|
|
||||||
for candidate in plan.workdirs {
|
|
||||||
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
|
|
||||||
assert!(candidate.blocking_reason.is_none());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2218,167 +2218,188 @@ impl SubjectRemovalFixture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
// Each case owns a complete isolated fixture; do not serialize independent
|
||||||
async fn erased_archive_plan_resend_and_reopen_replan_without_republishing_sources() {
|
// recovery paths inside one test. Transitions within each helper remain sequential.
|
||||||
for stage in ["planned", "executing", "failed"] {
|
macro_rules! journal_recovery_cases {
|
||||||
for restart in [false, true] {
|
($helper:ident; $($name:ident => ($($argument:expr),*)),+ $(,)?) => {
|
||||||
let mut f = SubjectRemovalFixture::new().await;
|
$(
|
||||||
f.disable();
|
#[tokio::test]
|
||||||
let policy = f
|
async fn $name() {
|
||||||
.api
|
$helper($($argument),*).await;
|
||||||
.config_store
|
|
||||||
.worker_retention_policy(TEST_WORKSPACE_ID)
|
|
||||||
.unwrap();
|
|
||||||
let conn = rusqlite::Connection::open(&f.api.config.database_path).unwrap();
|
|
||||||
let archive_parent = f
|
|
||||||
.dir
|
|
||||||
.path()
|
|
||||||
.join(".test-embedded-runtime-store/archives/workers");
|
|
||||||
if stage == "planned" {
|
|
||||||
conn.execute_batch("CREATE TRIGGER interrupt_removal BEFORE UPDATE OF state ON worker_removal_operations WHEN NEW.state='executing' BEGIN SELECT RAISE(ABORT,'injected execution interruption'); END;").unwrap();
|
|
||||||
} else {
|
|
||||||
// Fail Archive publication before any receipt. Suppress recording
|
|
||||||
// failure too to model the executing-at-Host-stop durable boundary.
|
|
||||||
std::fs::create_dir_all(archive_parent.parent().unwrap()).unwrap();
|
|
||||||
std::fs::write(&archive_parent, "publication blocked").unwrap();
|
|
||||||
if stage == "executing" {
|
|
||||||
conn.execute_batch("CREATE TRIGGER interrupt_removal BEFORE UPDATE OF state ON worker_removal_operations WHEN NEW.state='failed' BEGIN SELECT RAISE(ABORT,'injected failure-save interruption'); END;").unwrap();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
assert_ne!(f.remove().status, StatusCode::OK.as_u16());
|
)+
|
||||||
conn.execute_batch("DROP TRIGGER IF EXISTS interrupt_removal")
|
};
|
||||||
.unwrap();
|
|
||||||
if stage != "planned" {
|
|
||||||
std::fs::remove_file(&archive_parent).unwrap();
|
|
||||||
}
|
|
||||||
let old = f
|
|
||||||
.api
|
|
||||||
.config_store
|
|
||||||
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(serde_json::to_value(old.plan.state).unwrap(), stage);
|
|
||||||
assert_eq!(
|
|
||||||
old.plan.session_disposition,
|
|
||||||
worker_runtime::retention::SessionDisposition::Archive
|
|
||||||
);
|
|
||||||
f.api
|
|
||||||
.erase_subjektiv_journal_session(
|
|
||||||
&f.attr.session_id,
|
|
||||||
"erase-interrupted-archive-plan",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
if restart {
|
|
||||||
f = f.reopen().await;
|
|
||||||
}
|
|
||||||
let response = f.remove();
|
|
||||||
assert_eq!(
|
|
||||||
response.status,
|
|
||||||
StatusCode::OK.as_u16(),
|
|
||||||
"{stage}/{restart}: {}",
|
|
||||||
response.body
|
|
||||||
);
|
|
||||||
let saved = f
|
|
||||||
.api
|
|
||||||
.config_store
|
|
||||||
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
saved.plan.session_disposition,
|
|
||||||
worker_runtime::retention::SessionDisposition::Purge
|
|
||||||
);
|
|
||||||
assert_eq!(saved.plan.reason, old.plan.reason);
|
|
||||||
assert_ne!(saved.plan.operation_id, old.plan.operation_id);
|
|
||||||
let (state, fingerprint, detail): (String, String, String) = conn.query_row(
|
|
||||||
"SELECT o.state,o.input_fingerprint,a.detail FROM worker_removal_operations o JOIN worker_retention_audit_events a USING(operation_id) WHERE o.operation_id=?1 AND a.event_kind='erased_archive_superseded'",
|
|
||||||
[&old.plan.operation_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap();
|
|
||||||
assert_eq!(state, "stale");
|
|
||||||
assert_eq!(fingerprint, old.plan.input_fingerprint);
|
|
||||||
let detail: Value = serde_json::from_str(&detail).unwrap();
|
|
||||||
assert_eq!(detail["old_acceptance"], "unknown_archive_fenced");
|
|
||||||
assert!(detail["source_removed"].is_null());
|
|
||||||
f.assert_erased_removal();
|
|
||||||
assert_eq!(
|
|
||||||
f.api
|
|
||||||
.config_store
|
|
||||||
.worker_retention_policy(TEST_WORKSPACE_ID)
|
|
||||||
.unwrap(),
|
|
||||||
policy
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
async fn assert_erased_archive_plan_replans_without_republishing_sources(
|
||||||
async fn erased_completed_archive_receipt_is_audited_without_republishing_on_resend_or_reopen() {
|
stage: &str,
|
||||||
for restart in [false, true] {
|
restart: bool,
|
||||||
let mut f = SubjectRemovalFixture::new().await;
|
) {
|
||||||
f.disable();
|
let mut f = SubjectRemovalFixture::new().await;
|
||||||
let conn = rusqlite::Connection::open(&f.api.config.database_path).unwrap();
|
f.disable();
|
||||||
// Runtime has committed source_removed, but Backend commit/response is lost.
|
let policy = f
|
||||||
conn.execute_batch("CREATE TRIGGER interrupt_removal BEFORE DELETE ON worker_registry BEGIN SELECT RAISE(ABORT,'injected metadata commit interruption'); END;").unwrap();
|
.api
|
||||||
assert_ne!(f.remove().status, StatusCode::OK.as_u16());
|
.config_store
|
||||||
conn.execute_batch("DROP TRIGGER interrupt_removal")
|
.worker_retention_policy(TEST_WORKSPACE_ID)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let old = f
|
let conn = rusqlite::Connection::open(&f.api.config.database_path).unwrap();
|
||||||
.api
|
let archive_parent = f
|
||||||
.config_store
|
.dir
|
||||||
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
.path()
|
||||||
.unwrap()
|
.join(".test-embedded-runtime-store/archives/workers");
|
||||||
.unwrap();
|
if stage == "planned" {
|
||||||
assert_eq!(
|
conn.execute_batch("CREATE TRIGGER interrupt_removal BEFORE UPDATE OF state ON worker_removal_operations WHEN NEW.state='executing' BEGIN SELECT RAISE(ABORT,'injected execution interruption'); END;").unwrap();
|
||||||
old.plan.state,
|
} else {
|
||||||
crate::retention::WorkerRemovalPlanState::Failed
|
// Fail Archive publication before any receipt. Suppress recording
|
||||||
);
|
// failure too to model the executing-at-Host-stop durable boundary.
|
||||||
let receipt = f
|
std::fs::create_dir_all(archive_parent.parent().unwrap()).unwrap();
|
||||||
.api
|
std::fs::write(&archive_parent, "publication blocked").unwrap();
|
||||||
.runtime
|
if stage == "executing" {
|
||||||
.execute_worker_retention(&f.target, old.runtime_request.clone())
|
conn.execute_batch("CREATE TRIGGER interrupt_removal BEFORE UPDATE OF state ON worker_removal_operations WHEN NEW.state='failed' BEGIN SELECT RAISE(ABORT,'injected failure-save interruption'); END;").unwrap();
|
||||||
.unwrap();
|
|
||||||
assert!(receipt.source_removed);
|
|
||||||
let archive_id = old.plan.archive_id.as_ref().unwrap();
|
|
||||||
let archive_source = f
|
|
||||||
.dir
|
|
||||||
.path()
|
|
||||||
.join(".test-embedded-runtime-store/archives/workers")
|
|
||||||
.join(archive_id)
|
|
||||||
.join("session");
|
|
||||||
assert!(archive_source.exists());
|
|
||||||
// The domain tombstone commits even though retained-source resolution fails.
|
|
||||||
// Recovery must locate only this plan's exact reserved archive, not publish it.
|
|
||||||
assert!(
|
|
||||||
f.api
|
|
||||||
.erase_subjektiv_journal_session(&f.attr.session_id, "erase-transferred-original")
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert!(f.store.journal_session_erased(&f.host).unwrap());
|
|
||||||
if restart {
|
|
||||||
f = f.reopen().await;
|
|
||||||
}
|
}
|
||||||
let response = f.remove();
|
|
||||||
assert_eq!(
|
|
||||||
response.status,
|
|
||||||
StatusCode::OK.as_u16(),
|
|
||||||
"{restart}: {}",
|
|
||||||
response.body
|
|
||||||
);
|
|
||||||
let saved = f
|
|
||||||
.api
|
|
||||||
.config_store
|
|
||||||
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(saved.plan.operation_id, old.plan.operation_id);
|
|
||||||
assert_eq!(saved.plan.input_fingerprint, old.plan.input_fingerprint);
|
|
||||||
assert_eq!(saved.plan.session_disposition, old.plan.session_disposition);
|
|
||||||
let detail: String = conn.query_row("SELECT detail FROM worker_retention_audit_events WHERE operation_id=?1 AND event_kind='erased_removal_receipt'", [&old.plan.operation_id], |r| r.get(0)).unwrap();
|
|
||||||
let detail: Value = serde_json::from_str(&detail).unwrap();
|
|
||||||
assert_eq!(detail["source_removed"], true);
|
|
||||||
assert_eq!(detail["old_acceptance"], "receipt_confirmed");
|
|
||||||
assert!(!archive_source.exists());
|
|
||||||
f.assert_erased_removal();
|
|
||||||
}
|
}
|
||||||
|
assert_ne!(f.remove().status, StatusCode::OK.as_u16());
|
||||||
|
conn.execute_batch("DROP TRIGGER IF EXISTS interrupt_removal")
|
||||||
|
.unwrap();
|
||||||
|
if stage != "planned" {
|
||||||
|
std::fs::remove_file(&archive_parent).unwrap();
|
||||||
|
}
|
||||||
|
let old = f
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(old.plan.state).unwrap(), stage);
|
||||||
|
assert_eq!(
|
||||||
|
old.plan.session_disposition,
|
||||||
|
worker_runtime::retention::SessionDisposition::Archive
|
||||||
|
);
|
||||||
|
f.api
|
||||||
|
.erase_subjektiv_journal_session(&f.attr.session_id, "erase-interrupted-archive-plan")
|
||||||
|
.unwrap();
|
||||||
|
if restart {
|
||||||
|
f = f.reopen().await;
|
||||||
|
}
|
||||||
|
let response = f.remove();
|
||||||
|
assert_eq!(
|
||||||
|
response.status,
|
||||||
|
StatusCode::OK.as_u16(),
|
||||||
|
"{stage}/{restart}: {}",
|
||||||
|
response.body
|
||||||
|
);
|
||||||
|
let saved = f
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
saved.plan.session_disposition,
|
||||||
|
worker_runtime::retention::SessionDisposition::Purge
|
||||||
|
);
|
||||||
|
assert_eq!(saved.plan.reason, old.plan.reason);
|
||||||
|
assert_ne!(saved.plan.operation_id, old.plan.operation_id);
|
||||||
|
let (state, fingerprint, detail): (String, String, String) = conn.query_row(
|
||||||
|
"SELECT o.state,o.input_fingerprint,a.detail FROM worker_removal_operations o JOIN worker_retention_audit_events a USING(operation_id) WHERE o.operation_id=?1 AND a.event_kind='erased_archive_superseded'",
|
||||||
|
[&old.plan.operation_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap();
|
||||||
|
assert_eq!(state, "stale");
|
||||||
|
assert_eq!(fingerprint, old.plan.input_fingerprint);
|
||||||
|
let detail: Value = serde_json::from_str(&detail).unwrap();
|
||||||
|
assert_eq!(detail["old_acceptance"], "unknown_archive_fenced");
|
||||||
|
assert!(detail["source_removed"].is_null());
|
||||||
|
f.assert_erased_removal();
|
||||||
|
assert_eq!(
|
||||||
|
f.api
|
||||||
|
.config_store
|
||||||
|
.worker_retention_policy(TEST_WORKSPACE_ID)
|
||||||
|
.unwrap(),
|
||||||
|
policy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
journal_recovery_cases! {
|
||||||
|
assert_erased_archive_plan_replans_without_republishing_sources;
|
||||||
|
erased_archive_planned_resend_replans_without_republishing_sources => ("planned", false),
|
||||||
|
erased_archive_planned_reopen_replans_without_republishing_sources => ("planned", true),
|
||||||
|
erased_archive_executing_resend_replans_without_republishing_sources => ("executing", false),
|
||||||
|
erased_archive_executing_reopen_replans_without_republishing_sources => ("executing", true),
|
||||||
|
erased_archive_failed_resend_replans_without_republishing_sources => ("failed", false),
|
||||||
|
erased_archive_failed_reopen_replans_without_republishing_sources => ("failed", true),
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_erased_completed_archive_receipt_is_audited_without_republishing(restart: bool) {
|
||||||
|
let mut f = SubjectRemovalFixture::new().await;
|
||||||
|
f.disable();
|
||||||
|
let conn = rusqlite::Connection::open(&f.api.config.database_path).unwrap();
|
||||||
|
// Runtime has committed source_removed, but Backend commit/response is lost.
|
||||||
|
conn.execute_batch("CREATE TRIGGER interrupt_removal BEFORE DELETE ON worker_registry BEGIN SELECT RAISE(ABORT,'injected metadata commit interruption'); END;").unwrap();
|
||||||
|
assert_ne!(f.remove().status, StatusCode::OK.as_u16());
|
||||||
|
conn.execute_batch("DROP TRIGGER interrupt_removal")
|
||||||
|
.unwrap();
|
||||||
|
let old = f
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
old.plan.state,
|
||||||
|
crate::retention::WorkerRemovalPlanState::Failed
|
||||||
|
);
|
||||||
|
let receipt = f
|
||||||
|
.api
|
||||||
|
.runtime
|
||||||
|
.execute_worker_retention(&f.target, old.runtime_request.clone())
|
||||||
|
.unwrap();
|
||||||
|
assert!(receipt.source_removed);
|
||||||
|
let archive_id = old.plan.archive_id.as_ref().unwrap();
|
||||||
|
let archive_source = f
|
||||||
|
.dir
|
||||||
|
.path()
|
||||||
|
.join(".test-embedded-runtime-store/archives/workers")
|
||||||
|
.join(archive_id)
|
||||||
|
.join("session");
|
||||||
|
assert!(archive_source.exists());
|
||||||
|
// The domain tombstone commits even though retained-source resolution fails.
|
||||||
|
// Recovery must locate only this plan's exact reserved archive, not publish it.
|
||||||
|
assert!(
|
||||||
|
f.api
|
||||||
|
.erase_subjektiv_journal_session(&f.attr.session_id, "erase-transferred-original")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(f.store.journal_session_erased(&f.host).unwrap());
|
||||||
|
if restart {
|
||||||
|
f = f.reopen().await;
|
||||||
|
}
|
||||||
|
let response = f.remove();
|
||||||
|
assert_eq!(
|
||||||
|
response.status,
|
||||||
|
StatusCode::OK.as_u16(),
|
||||||
|
"{restart}: {}",
|
||||||
|
response.body
|
||||||
|
);
|
||||||
|
let saved = f
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(saved.plan.operation_id, old.plan.operation_id);
|
||||||
|
assert_eq!(saved.plan.input_fingerprint, old.plan.input_fingerprint);
|
||||||
|
assert_eq!(saved.plan.session_disposition, old.plan.session_disposition);
|
||||||
|
let detail: String = conn.query_row("SELECT detail FROM worker_retention_audit_events WHERE operation_id=?1 AND event_kind='erased_removal_receipt'", [&old.plan.operation_id], |r| r.get(0)).unwrap();
|
||||||
|
let detail: Value = serde_json::from_str(&detail).unwrap();
|
||||||
|
assert_eq!(detail["source_removed"], true);
|
||||||
|
assert_eq!(detail["old_acceptance"], "receipt_confirmed");
|
||||||
|
assert!(!archive_source.exists());
|
||||||
|
f.assert_erased_removal();
|
||||||
|
}
|
||||||
|
|
||||||
|
journal_recovery_cases! {
|
||||||
|
assert_erased_completed_archive_receipt_is_audited_without_republishing;
|
||||||
|
erased_completed_archive_receipt_resend_is_audited_without_republishing => (false),
|
||||||
|
erased_completed_archive_receipt_reopen_is_audited_without_republishing => (true),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2438,206 +2459,206 @@ async fn erased_archive_without_receipt_or_inventory_remains_unknown_without_rep
|
|||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
async fn assert_erased_archive_retained_diagnostics_receipt_finalizes_only_after_physical_ack(
|
||||||
async fn erased_archive_retained_diagnostics_receipt_finalizes_only_after_physical_ack_on_resend_or_reopen()
|
restart: bool,
|
||||||
{
|
interrupt_erase: bool,
|
||||||
for restart in [false, true] {
|
) {
|
||||||
for interrupt_erase in [false, true] {
|
let mut f = SubjectRemovalFixture::new().await;
|
||||||
let mut f = SubjectRemovalFixture::new().await;
|
f.disable();
|
||||||
f.disable();
|
let policy = f
|
||||||
let policy = f
|
.api
|
||||||
.api
|
.config_store
|
||||||
.config_store
|
.worker_retention_policy(TEST_WORKSPACE_ID)
|
||||||
.worker_retention_policy(TEST_WORKSPACE_ID)
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let policy = f
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.update_worker_retention_policy(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&policy.policy_digest,
|
||||||
|
&crate::retention::WorkerRetentionPolicyUpdate {
|
||||||
|
policy_id: "fixture-retained-diagnostics".into(),
|
||||||
|
session_disposition: policy.session_disposition,
|
||||||
|
metadata_disposition: policy.metadata_disposition,
|
||||||
|
archive_retention: policy.archive_retention,
|
||||||
|
diagnostics_disposition: worker_runtime::retention::DiagnosticsDisposition::Retain,
|
||||||
|
diagnostics_retention_seconds: Some(3600),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let worker_root = session_root(f.dir.path(), &f.target)
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.to_path_buf();
|
||||||
|
std::fs::create_dir_all(worker_root.join("runs/fixture-run")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
worker_root.join("runs/fixture-run/worker.out.log"),
|
||||||
|
"diagnostic original",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let conn = rusqlite::Connection::open(&f.api.config.database_path).unwrap();
|
||||||
|
// Real Runtime receipt exists; Backend commit/response is interrupted.
|
||||||
|
conn.execute_batch("CREATE TRIGGER interrupt_removal BEFORE DELETE ON worker_registry BEGIN SELECT RAISE(ABORT,'injected metadata commit interruption'); END;").unwrap();
|
||||||
|
assert_ne!(f.remove().status, StatusCode::OK.as_u16());
|
||||||
|
conn.execute_batch("DROP TRIGGER interrupt_removal")
|
||||||
|
.unwrap();
|
||||||
|
let old = f
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let runtime_root = f.dir.path().join(".test-embedded-runtime-store");
|
||||||
|
let receipt_path = runtime_root
|
||||||
|
.join("retention/operations")
|
||||||
|
.join(format!("{}.json", old.plan.operation_id));
|
||||||
|
let receipt_bytes = std::fs::read(&receipt_path).unwrap();
|
||||||
|
let receipt = f
|
||||||
|
.api
|
||||||
|
.runtime
|
||||||
|
.execute_worker_retention(&f.target, old.runtime_request.clone())
|
||||||
|
.unwrap();
|
||||||
|
assert!(receipt.source_removed);
|
||||||
|
assert!(receipt.diagnostics_retained);
|
||||||
|
let diagnostics_root = runtime_root
|
||||||
|
.join("archives/diagnostics")
|
||||||
|
.join(&old.plan.operation_id);
|
||||||
|
let diagnostics_manifest = diagnostics_root.join("manifest.json");
|
||||||
|
let diagnostics_bytes = std::fs::read(&diagnostics_manifest).unwrap();
|
||||||
|
let archive_source = runtime_root
|
||||||
|
.join("archives/workers")
|
||||||
|
.join(old.plan.archive_id.as_ref().unwrap())
|
||||||
|
.join("session");
|
||||||
|
assert!(diagnostics_root.exists());
|
||||||
|
assert!(archive_source.exists());
|
||||||
|
// Domain erasure succeeds, but no physical acknowledgement can be
|
||||||
|
// claimed from the old retained locator after aggregate removal.
|
||||||
|
assert!(
|
||||||
|
f.api
|
||||||
|
.erase_subjektiv_journal_session(&f.attr.session_id, "erase-retained-diagnostics")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
if interrupt_erase {
|
||||||
|
// Runtime must reject mismatched diagnostics identity rather than
|
||||||
|
// acknowledge removal of an unrelated operation. No fake Host ack.
|
||||||
|
let mut damaged: Value = serde_json::from_slice(&diagnostics_bytes).unwrap();
|
||||||
|
damaged["input_fingerprint"] = json!("unrelated-operation");
|
||||||
|
std::fs::write(&diagnostics_manifest, serde_json::to_vec(&damaged).unwrap()).unwrap();
|
||||||
|
let response = f.remove();
|
||||||
|
assert_eq!(
|
||||||
|
response.status,
|
||||||
|
StatusCode::CONFLICT.as_u16(),
|
||||||
|
"{}",
|
||||||
|
response.body
|
||||||
|
);
|
||||||
|
let body: Value = serde_json::from_str(&response.body).unwrap();
|
||||||
|
assert_eq!(body["code"], "erased_removal_reconciliation_unconfirmed");
|
||||||
|
assert!(
|
||||||
|
f.api
|
||||||
|
.store
|
||||||
|
.get_worker_registry(TEST_WORKSPACE_ID, &f.target)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.is_some()
|
||||||
let policy = f
|
);
|
||||||
.api
|
let count: i64 = conn.query_row("SELECT COUNT(*) FROM worker_retention_audit_events WHERE operation_id=?1 AND event_kind='erased_removal_receipt'", [&old.plan.operation_id], |r| r.get(0)).unwrap();
|
||||||
.config_store
|
assert_eq!(
|
||||||
.update_worker_retention_policy(
|
count, 0,
|
||||||
TEST_WORKSPACE_ID,
|
"failed acknowledgement cannot authorize finalization/audit"
|
||||||
&policy.policy_digest,
|
);
|
||||||
&crate::retention::WorkerRetentionPolicyUpdate {
|
assert_eq!(std::fs::read(&receipt_path).unwrap(), receipt_bytes);
|
||||||
policy_id: "fixture-retained-diagnostics".into(),
|
let waiting = f
|
||||||
session_disposition: policy.session_disposition,
|
.api
|
||||||
metadata_disposition: policy.metadata_disposition,
|
.config_store
|
||||||
archive_retention: policy.archive_retention,
|
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
||||||
diagnostics_disposition:
|
.unwrap()
|
||||||
worker_runtime::retention::DiagnosticsDisposition::Retain,
|
.unwrap();
|
||||||
diagnostics_retention_seconds: Some(3600),
|
assert_eq!(waiting.plan.operation_id, old.plan.operation_id);
|
||||||
},
|
assert_eq!(waiting.plan.input_fingerprint, old.plan.input_fingerprint);
|
||||||
)
|
assert_eq!(
|
||||||
.unwrap();
|
waiting.plan.state,
|
||||||
let worker_root = session_root(f.dir.path(), &f.target)
|
crate::retention::WorkerRemovalPlanState::Executing
|
||||||
.parent()
|
);
|
||||||
.unwrap()
|
let plans: i64 = conn
|
||||||
.to_path_buf();
|
.query_row(
|
||||||
std::fs::create_dir_all(worker_root.join("runs/fixture-run")).unwrap();
|
"SELECT COUNT(*) FROM worker_removal_operations WHERE worker_id=?1",
|
||||||
std::fs::write(
|
[&f.target.worker_id],
|
||||||
worker_root.join("runs/fixture-run/worker.out.log"),
|
|r| r.get(0),
|
||||||
"diagnostic original",
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let conn = rusqlite::Connection::open(&f.api.config.database_path).unwrap();
|
assert_eq!(
|
||||||
// Real Runtime receipt exists; Backend commit/response is interrupted.
|
plans, 1,
|
||||||
conn.execute_batch("CREATE TRIGGER interrupt_removal BEFORE DELETE ON worker_registry BEGIN SELECT RAISE(ABORT,'injected metadata commit interruption'); END;").unwrap();
|
"missing physical ack cannot authorize a replacement plan"
|
||||||
assert_ne!(f.remove().status, StatusCode::OK.as_u16());
|
);
|
||||||
conn.execute_batch("DROP TRIGGER interrupt_removal")
|
std::fs::write(&diagnostics_manifest, &diagnostics_bytes).unwrap();
|
||||||
.unwrap();
|
|
||||||
let old = f
|
|
||||||
.api
|
|
||||||
.config_store
|
|
||||||
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let runtime_root = f.dir.path().join(".test-embedded-runtime-store");
|
|
||||||
let receipt_path = runtime_root
|
|
||||||
.join("retention/operations")
|
|
||||||
.join(format!("{}.json", old.plan.operation_id));
|
|
||||||
let receipt_bytes = std::fs::read(&receipt_path).unwrap();
|
|
||||||
let receipt = f
|
|
||||||
.api
|
|
||||||
.runtime
|
|
||||||
.execute_worker_retention(&f.target, old.runtime_request.clone())
|
|
||||||
.unwrap();
|
|
||||||
assert!(receipt.source_removed);
|
|
||||||
assert!(receipt.diagnostics_retained);
|
|
||||||
let diagnostics_root = runtime_root
|
|
||||||
.join("archives/diagnostics")
|
|
||||||
.join(&old.plan.operation_id);
|
|
||||||
let diagnostics_manifest = diagnostics_root.join("manifest.json");
|
|
||||||
let diagnostics_bytes = std::fs::read(&diagnostics_manifest).unwrap();
|
|
||||||
let archive_source = runtime_root
|
|
||||||
.join("archives/workers")
|
|
||||||
.join(old.plan.archive_id.as_ref().unwrap())
|
|
||||||
.join("session");
|
|
||||||
assert!(diagnostics_root.exists());
|
|
||||||
assert!(archive_source.exists());
|
|
||||||
// Domain erasure succeeds, but no physical acknowledgement can be
|
|
||||||
// claimed from the old retained locator after aggregate removal.
|
|
||||||
assert!(
|
|
||||||
f.api
|
|
||||||
.erase_subjektiv_journal_session(
|
|
||||||
&f.attr.session_id,
|
|
||||||
"erase-retained-diagnostics"
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
if interrupt_erase {
|
|
||||||
// Runtime must reject mismatched diagnostics identity rather than
|
|
||||||
// acknowledge removal of an unrelated operation. No fake Host ack.
|
|
||||||
let mut damaged: Value = serde_json::from_slice(&diagnostics_bytes).unwrap();
|
|
||||||
damaged["input_fingerprint"] = json!("unrelated-operation");
|
|
||||||
std::fs::write(&diagnostics_manifest, serde_json::to_vec(&damaged).unwrap())
|
|
||||||
.unwrap();
|
|
||||||
let response = f.remove();
|
|
||||||
assert_eq!(
|
|
||||||
response.status,
|
|
||||||
StatusCode::CONFLICT.as_u16(),
|
|
||||||
"{}",
|
|
||||||
response.body
|
|
||||||
);
|
|
||||||
let body: Value = serde_json::from_str(&response.body).unwrap();
|
|
||||||
assert_eq!(body["code"], "erased_removal_reconciliation_unconfirmed");
|
|
||||||
assert!(
|
|
||||||
f.api
|
|
||||||
.store
|
|
||||||
.get_worker_registry(TEST_WORKSPACE_ID, &f.target)
|
|
||||||
.unwrap()
|
|
||||||
.is_some()
|
|
||||||
);
|
|
||||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM worker_retention_audit_events WHERE operation_id=?1 AND event_kind='erased_removal_receipt'", [&old.plan.operation_id], |r| r.get(0)).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
count, 0,
|
|
||||||
"failed acknowledgement cannot authorize finalization/audit"
|
|
||||||
);
|
|
||||||
assert_eq!(std::fs::read(&receipt_path).unwrap(), receipt_bytes);
|
|
||||||
let waiting = f
|
|
||||||
.api
|
|
||||||
.config_store
|
|
||||||
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(waiting.plan.operation_id, old.plan.operation_id);
|
|
||||||
assert_eq!(waiting.plan.input_fingerprint, old.plan.input_fingerprint);
|
|
||||||
assert_eq!(
|
|
||||||
waiting.plan.state,
|
|
||||||
crate::retention::WorkerRemovalPlanState::Executing
|
|
||||||
);
|
|
||||||
let plans: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM worker_removal_operations WHERE worker_id=?1",
|
|
||||||
[&f.target.worker_id],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
plans, 1,
|
|
||||||
"missing physical ack cannot authorize a replacement plan"
|
|
||||||
);
|
|
||||||
std::fs::write(&diagnostics_manifest, &diagnostics_bytes).unwrap();
|
|
||||||
}
|
|
||||||
if restart {
|
|
||||||
f = f.reopen().await;
|
|
||||||
}
|
|
||||||
let response = f.remove();
|
|
||||||
assert_eq!(
|
|
||||||
response.status,
|
|
||||||
StatusCode::OK.as_u16(),
|
|
||||||
"restart={restart}, interrupted={interrupt_erase}: {}",
|
|
||||||
response.body
|
|
||||||
);
|
|
||||||
assert!(!diagnostics_root.exists());
|
|
||||||
assert!(!archive_source.exists());
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read(&receipt_path).unwrap(),
|
|
||||||
receipt_bytes,
|
|
||||||
"historical Runtime receipt must not be rewritten"
|
|
||||||
);
|
|
||||||
let saved = f
|
|
||||||
.api
|
|
||||||
.config_store
|
|
||||||
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(saved.plan.operation_id, old.plan.operation_id);
|
|
||||||
assert_eq!(saved.plan.input_fingerprint, old.plan.input_fingerprint);
|
|
||||||
assert_eq!(
|
|
||||||
saved.plan.diagnostics_disposition,
|
|
||||||
old.plan.diagnostics_disposition
|
|
||||||
);
|
|
||||||
let detail: String = conn.query_row("SELECT detail FROM worker_retention_audit_events WHERE operation_id=?1 AND event_kind='erased_removal_receipt'", [&old.plan.operation_id], |r| r.get(0)).unwrap();
|
|
||||||
let detail: Value = serde_json::from_str(&detail).unwrap();
|
|
||||||
assert_eq!(detail["source_removed"], true);
|
|
||||||
assert_eq!(detail["old_diagnostics_retained"], true);
|
|
||||||
assert_eq!(
|
|
||||||
detail["runtime_erasure_ack"]["session_id"],
|
|
||||||
f.attr.session_id
|
|
||||||
);
|
|
||||||
let diagnostics_catalog: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM worker_diagnostics_archives WHERE operation_id=?1",
|
|
||||||
[&old.plan.operation_id],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(diagnostics_catalog, 0);
|
|
||||||
let tombstone_archive: Option<String> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT archive_id FROM worker_tombstones WHERE operation_id=?1",
|
|
||||||
[&old.plan.operation_id],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(tombstone_archive.is_none());
|
|
||||||
f.assert_erased_removal();
|
|
||||||
assert_eq!(
|
|
||||||
f.api
|
|
||||||
.config_store
|
|
||||||
.worker_retention_policy(TEST_WORKSPACE_ID)
|
|
||||||
.unwrap(),
|
|
||||||
Some(policy)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if restart {
|
||||||
|
f = f.reopen().await;
|
||||||
|
}
|
||||||
|
let response = f.remove();
|
||||||
|
assert_eq!(
|
||||||
|
response.status,
|
||||||
|
StatusCode::OK.as_u16(),
|
||||||
|
"restart={restart}, interrupted={interrupt_erase}: {}",
|
||||||
|
response.body
|
||||||
|
);
|
||||||
|
assert!(!diagnostics_root.exists());
|
||||||
|
assert!(!archive_source.exists());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&receipt_path).unwrap(),
|
||||||
|
receipt_bytes,
|
||||||
|
"historical Runtime receipt must not be rewritten"
|
||||||
|
);
|
||||||
|
let saved = f
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.recover_worker_removal_execution(TEST_WORKSPACE_ID, &f.target)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(saved.plan.operation_id, old.plan.operation_id);
|
||||||
|
assert_eq!(saved.plan.input_fingerprint, old.plan.input_fingerprint);
|
||||||
|
assert_eq!(
|
||||||
|
saved.plan.diagnostics_disposition,
|
||||||
|
old.plan.diagnostics_disposition
|
||||||
|
);
|
||||||
|
let detail: String = conn.query_row("SELECT detail FROM worker_retention_audit_events WHERE operation_id=?1 AND event_kind='erased_removal_receipt'", [&old.plan.operation_id], |r| r.get(0)).unwrap();
|
||||||
|
let detail: Value = serde_json::from_str(&detail).unwrap();
|
||||||
|
assert_eq!(detail["source_removed"], true);
|
||||||
|
assert_eq!(detail["old_diagnostics_retained"], true);
|
||||||
|
assert_eq!(
|
||||||
|
detail["runtime_erasure_ack"]["session_id"],
|
||||||
|
f.attr.session_id
|
||||||
|
);
|
||||||
|
let diagnostics_catalog: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM worker_diagnostics_archives WHERE operation_id=?1",
|
||||||
|
[&old.plan.operation_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(diagnostics_catalog, 0);
|
||||||
|
let tombstone_archive: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT archive_id FROM worker_tombstones WHERE operation_id=?1",
|
||||||
|
[&old.plan.operation_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(tombstone_archive.is_none());
|
||||||
|
f.assert_erased_removal();
|
||||||
|
assert_eq!(
|
||||||
|
f.api
|
||||||
|
.config_store
|
||||||
|
.worker_retention_policy(TEST_WORKSPACE_ID)
|
||||||
|
.unwrap(),
|
||||||
|
Some(policy)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
journal_recovery_cases! {
|
||||||
|
assert_erased_archive_retained_diagnostics_receipt_finalizes_only_after_physical_ack;
|
||||||
|
erased_archive_retained_diagnostics_resend_finalizes_only_after_physical_ack => (false, false),
|
||||||
|
erased_archive_retained_diagnostics_interrupted_resend_finalizes_only_after_physical_ack => (false, true),
|
||||||
|
erased_archive_retained_diagnostics_reopen_finalizes_only_after_physical_ack => (true, false),
|
||||||
|
erased_archive_retained_diagnostics_interrupted_reopen_finalizes_only_after_physical_ack => (true, true),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -730,13 +730,31 @@ async fn unsafe_names_and_oversized_text_cannot_publish_and_reads_truncate_at_ut
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn omitted_discovery_limits_use_backend_maximum_pages_in_tools_and_native_wip() {
|
async fn omitted_discovery_limits_use_backend_maximum_pages_in_tools_and_native_wip() {
|
||||||
let f = Fixture::new(["read_write", "read_only"]).await;
|
let f = Fixture::new(["read_write", "read_only"]).await;
|
||||||
let mut expected = std::collections::BTreeSet::new();
|
// This case owns discovery defaults, not repeated mutation routing. Seed through
|
||||||
for index in 0..201 {
|
// the real Drive owner (including blobs/receipts); reads below retain signed tools/WIP.
|
||||||
let created = f
|
let drive = f.api.drive().await.unwrap();
|
||||||
.create(&format!("default-page-{index:03}.md"), "bounded")
|
let expected = tokio::task::spawn_blocking(move || {
|
||||||
.await;
|
let root = drive.root().unwrap().id;
|
||||||
expected.insert(entry(&created)["node_id"].as_str().unwrap().to_owned());
|
let mut expected = std::collections::BTreeSet::new();
|
||||||
}
|
for index in 0..201 {
|
||||||
|
let created = drive
|
||||||
|
.mutate(
|
||||||
|
&format!("default-page-seed-{index:03}"),
|
||||||
|
"pagination-fixture",
|
||||||
|
workspace_drive::Mutation::CreateFile {
|
||||||
|
parent: root,
|
||||||
|
name: format!("default-page-{index:03}.md"),
|
||||||
|
content_type: "text/markdown".into(),
|
||||||
|
bytes: b"bounded".to_vec(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
expected.insert(String::from(created.node.id));
|
||||||
|
}
|
||||||
|
expected
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let native = native_tools(&f.features[1]);
|
let native = native_tools(&f.features[1]);
|
||||||
let root = value(
|
let root = value(
|
||||||
native_tool(&native, "Inspect", json!({"path":"/drive","refresh":true}))
|
native_tool(&native, "Inspect", json!({"path":"/drive","refresh":true}))
|
||||||
|
|||||||
@@ -401,6 +401,11 @@ async fn deletion_fences_new_requests_then_drains_without_callback_starvation()
|
|||||||
let callback = gate.admit(true).expect("parent may still need callback");
|
let callback = gate.admit(true).expect("parent may still need callback");
|
||||||
reply.send(Ok(read_result())).unwrap();
|
reply.send(Ok(read_result())).unwrap();
|
||||||
assert_json(long.await.unwrap(), StatusCode::OK).await;
|
assert_json(long.await.unwrap(), StatusCode::OK).await;
|
||||||
|
// HTTP completion may precede cleanup of retained DB/Host request leases.
|
||||||
|
// Keep the callback alive while observing the real parent-drain condition.
|
||||||
|
tokio::time::timeout(BOUND, gate.wait_for_parent_requests_to_drain())
|
||||||
|
.await
|
||||||
|
.expect("completed HTTP request retained its admission through the drain deadline");
|
||||||
// No new callbacks may prolong the drain once all parents have left.
|
// No new callbacks may prolong the drain once all parents have left.
|
||||||
assert!(gate.admit(true).is_none());
|
assert!(gate.admit(true).is_none());
|
||||||
assert!(futures::poll!(&mut deletion).is_pending());
|
assert!(futures::poll!(&mut deletion).is_pending());
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ impl WorkspaceAdmission {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Observe parent cleanup without releasing a callback held by the test.
|
||||||
|
// A reply can arrive before the DB/Host job drops its retained request lease.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) async fn wait_for_parent_requests_to_drain(&self) {
|
||||||
|
loop {
|
||||||
|
let changed = self.changed.notified();
|
||||||
|
tokio::pin!(changed);
|
||||||
|
changed.as_mut().enable();
|
||||||
|
{
|
||||||
|
let state = self.state.lock().expect("Workspace admission poisoned");
|
||||||
|
assert!(
|
||||||
|
state.deleting,
|
||||||
|
"parent drain observation requires a deletion fence"
|
||||||
|
);
|
||||||
|
if state.requests == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn lock_deletion(self: &Arc<Self>) -> DeletionLease {
|
pub(super) async fn lock_deletion(self: &Arc<Self>) -> DeletionLease {
|
||||||
let serial = self.deletion.clone().lock_owned().await;
|
let serial = self.deletion.clone().lock_owned().await;
|
||||||
self.state
|
self.state
|
||||||
@@ -135,3 +157,43 @@ impl Drop for DeletionLease {
|
|||||||
.deleting = false;
|
.deleting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db_executor::DbOperationLifetime;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parent_drain_waits_for_retained_job_cleanup_without_waiting_for_callbacks() {
|
||||||
|
let gate = Arc::new(WorkspaceAdmission::default());
|
||||||
|
let parent = gate.admit(false).unwrap();
|
||||||
|
let retained_job = parent.retain();
|
||||||
|
let mut deletion = Box::pin(gate.lock_deletion());
|
||||||
|
assert!(futures::poll!(&mut deletion).is_pending());
|
||||||
|
let callback = gate.admit(true).unwrap();
|
||||||
|
drop(parent);
|
||||||
|
let mut parents_drained = Box::pin(gate.wait_for_parent_requests_to_drain());
|
||||||
|
assert!(futures::poll!(&mut parents_drained).is_pending());
|
||||||
|
assert!(
|
||||||
|
gate.admit(true).is_some(),
|
||||||
|
"retained parent may still need callbacks"
|
||||||
|
);
|
||||||
|
drop(retained_job);
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), parents_drained)
|
||||||
|
.await
|
||||||
|
.expect("parent cleanup did not notify the drain observer");
|
||||||
|
assert!(gate.admit(true).is_none());
|
||||||
|
assert!(gate.admit(false).is_none());
|
||||||
|
assert!(
|
||||||
|
futures::poll!(&mut deletion).is_pending(),
|
||||||
|
"callback still owns its lease"
|
||||||
|
);
|
||||||
|
drop(callback);
|
||||||
|
let deletion = tokio::time::timeout(std::time::Duration::from_secs(5), deletion)
|
||||||
|
.await
|
||||||
|
.expect("callback cleanup did not finish deletion drain");
|
||||||
|
assert!(gate.admit(true).is_none());
|
||||||
|
drop(deletion);
|
||||||
|
assert!(gate.admit(false).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15616,6 +15616,10 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> {
|
|||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "store_test_support.rs"]
|
||||||
|
mod test_support;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
include!("assignment_tracking_tests.rs");
|
include!("assignment_tracking_tests.rs");
|
||||||
@@ -15653,7 +15657,7 @@ mod tests {
|
|||||||
fn drive_grants_migrate_and_preserve_audit_through_revoke_worker_delete_and_restart() {
|
fn drive_grants_migrate_and_preserve_audit_through_revoke_worker_delete_and_restart() {
|
||||||
let t = tempfile::tempdir().unwrap();
|
let t = tempfile::tempdir().unwrap();
|
||||||
let path = t.path().join("server.db");
|
let path = t.path().join("server.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
let legacy = Connection::open_in_memory().unwrap();
|
let legacy = Connection::open_in_memory().unwrap();
|
||||||
configure_sqlite(&legacy).unwrap();
|
configure_sqlite(&legacy).unwrap();
|
||||||
legacy.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);
|
legacy.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);
|
||||||
@@ -15778,7 +15782,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn drive_grants_baseline_constraints_and_worker_lifetime_fail_closed() {
|
fn drive_grants_baseline_constraints_and_worker_lifetime_fail_closed() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
let proposed = drive_grant_fixture(&store);
|
let proposed = drive_grant_fixture(&store);
|
||||||
let first = store.create_workspace_drive_grant(&proposed).unwrap();
|
let first = store.create_workspace_drive_grant(&proposed).unwrap();
|
||||||
store.with_conn(|c| {
|
store.with_conn(|c| {
|
||||||
@@ -16139,7 +16143,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn external_workdir_grant_persists_typed_source_and_fences_generations() {
|
async fn external_workdir_grant_persists_typed_source_and_fences_generations() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store
|
store
|
||||||
.upsert_account(&AccountRecord {
|
.upsert_account(&AccountRecord {
|
||||||
account_id: "owner".to_string(),
|
account_id: "owner".to_string(),
|
||||||
@@ -16890,39 +16894,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_v59_upgrade_adds_runtime_removal_authority_atomically() {
|
fn schema_v59_upgrade_adds_runtime_removal_authority_atomically() -> Result<()> {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let path = temp.path().join("server.db");
|
let path = temp.path().join("server.db");
|
||||||
prepare_retained_schema(&path, 59);
|
prepare_retained_schema(&path, 59);
|
||||||
|
|
||||||
let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let conn = Connection::open(&path)?;
|
||||||
migrated
|
configure_sqlite(&conn)?;
|
||||||
.with_conn(|conn| {
|
migrate_runtime_removal_operations_v59_to_v60(&conn)?;
|
||||||
let table_count: i64 = conn.query_row(
|
assert_eq!(current_schema_version(&conn)?, 60);
|
||||||
"SELECT COUNT(*) FROM sqlite_master \
|
let table_count: i64 = conn.query_row(
|
||||||
WHERE type = 'table' AND name = 'runtime_removal_operations'",
|
"SELECT COUNT(*) FROM sqlite_master \
|
||||||
[],
|
WHERE type = 'table' AND name = 'runtime_removal_operations'",
|
||||||
|row| row.get(0),
|
[],
|
||||||
)?;
|
|row| row.get(0),
|
||||||
let trigger_count: i64 = conn.query_row(
|
)?;
|
||||||
"SELECT COUNT(*) FROM sqlite_master \
|
let trigger_count: i64 = conn.query_row(
|
||||||
WHERE type = 'trigger' AND (\
|
"SELECT COUNT(*) FROM sqlite_master \
|
||||||
name LIKE '%_blocked_by_runtime_removal' OR \
|
WHERE type = 'trigger' AND (\
|
||||||
name LIKE 'runtime_binding_%_blocked_by_removal'\
|
name LIKE '%_blocked_by_runtime_removal' OR \
|
||||||
)",
|
name LIKE 'runtime_binding_%_blocked_by_removal'\
|
||||||
[],
|
)",
|
||||||
|row| row.get(0),
|
[],
|
||||||
)?;
|
|row| row.get(0),
|
||||||
let foreign_key_failures: i64 =
|
)?;
|
||||||
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
let foreign_key_failures: i64 =
|
||||||
row.get(0)
|
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||||
})?;
|
row.get(0)
|
||||||
assert_eq!(table_count, 1);
|
})?;
|
||||||
assert_eq!(trigger_count, 18);
|
assert_eq!(table_count, 1);
|
||||||
assert_eq!(foreign_key_failures, 0);
|
assert_eq!(trigger_count, 18);
|
||||||
Ok(())
|
assert_eq!(foreign_key_failures, 0);
|
||||||
})
|
Ok(())
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -16931,12 +16934,10 @@ mod tests {
|
|||||||
let path = temp.path().join("server.db");
|
let path = temp.path().join("server.db");
|
||||||
prepare_retained_schema(&path, 60);
|
prepare_retained_schema(&path, 60);
|
||||||
|
|
||||||
drop(SqliteWorkspaceStore::open_for_test(&path).unwrap());
|
|
||||||
let conn = Connection::open(&path).unwrap();
|
let conn = Connection::open(&path).unwrap();
|
||||||
assert_eq!(
|
configure_sqlite(&conn).unwrap();
|
||||||
current_schema_version(&conn).unwrap(),
|
migrate_worker_run_generation_v60_to_v61(&conn).unwrap();
|
||||||
LATEST_SCHEMA_VERSION
|
assert_eq!(current_schema_version(&conn).unwrap(), 61);
|
||||||
);
|
|
||||||
let columns = conn
|
let columns = conn
|
||||||
.prepare("PRAGMA table_info(worker_removal_operations)")
|
.prepare("PRAGMA table_info(worker_removal_operations)")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -17506,34 +17507,100 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
include!("migration_entrypoint_tests.rs");
|
include!("migration_entrypoint_tests.rs");
|
||||||
include!("legacy_evidence_column_tests.rs");
|
include!("legacy_evidence_column_tests.rs");
|
||||||
|
|
||||||
fn prepare_retained_schema(path: &Path, version: i64) {
|
#[test]
|
||||||
prepare_schema_v52(path);
|
fn retained_fixture_copies_isolate_seed_rows_and_keep_connection_constraints() {
|
||||||
let conn = Connection::open(path).unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let first = temp.path().join("first.db");
|
||||||
|
prepare_retained_schema(&first, 59);
|
||||||
|
let conn = Connection::open(first).unwrap();
|
||||||
|
conn.execute("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','1','1')", []).unwrap();
|
||||||
|
let second = temp.path().join("second.db");
|
||||||
|
prepare_retained_schema(&second, 59);
|
||||||
|
let conn = Connection::open(second).unwrap();
|
||||||
configure_sqlite(&conn).unwrap();
|
configure_sqlite(&conn).unwrap();
|
||||||
for migration in MIGRATIONS
|
assert_eq!(current_schema_version(&conn).unwrap(), 59);
|
||||||
.iter()
|
verify_schema_history(&conn, 59).unwrap();
|
||||||
.filter(|migration| migration.version > 52 && migration.version <= version)
|
assert_eq!(
|
||||||
{
|
conn.query_row("SELECT COUNT(*) FROM accounts", [], |row| row
|
||||||
(migration.apply)(&conn).unwrap();
|
.get::<_, i64>(0))
|
||||||
|
.unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
|
||||||
|
.unwrap(),
|
||||||
|
"wal"
|
||||||
|
);
|
||||||
|
assert!(conn.execute("INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('invalid','missing','Invalid','active','1','1')", []).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_retained_schema(path: &Path, version: i64) {
|
||||||
|
// Cache only empty preconditions, built from frozen DDL and real retained
|
||||||
|
// migrations. The test's seeded rows and migration under test are never shared.
|
||||||
|
static TEMPLATES: std::sync::OnceLock<Mutex<std::collections::BTreeMap<i64, Connection>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
assert!((52..=LATEST_SCHEMA_VERSION).contains(&version));
|
||||||
|
let mut templates = TEMPLATES
|
||||||
|
.get_or_init(|| Mutex::new(Default::default()))
|
||||||
|
.lock()
|
||||||
|
.unwrap();
|
||||||
|
if !templates.contains_key(&version) {
|
||||||
|
let mut source = Connection::open_in_memory().unwrap();
|
||||||
|
let baseline = if let Some((&baseline, prior)) = templates.range(..=version).next_back()
|
||||||
|
{
|
||||||
|
Backup::new(prior, &mut source)
|
||||||
|
.unwrap()
|
||||||
|
.run_to_completion(128, Duration::from_millis(1), None)
|
||||||
|
.unwrap();
|
||||||
|
configure_sqlite(&source).unwrap();
|
||||||
|
baseline
|
||||||
|
} else {
|
||||||
|
build_schema_v52(&source);
|
||||||
|
52
|
||||||
|
};
|
||||||
|
for migration in MIGRATIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|migration| migration.version > baseline && migration.version <= version)
|
||||||
|
{
|
||||||
|
(migration.apply)(&source).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(current_schema_version(&source).unwrap(), version);
|
||||||
|
templates.insert(version, source);
|
||||||
}
|
}
|
||||||
|
let mut conn = Connection::open(path).unwrap();
|
||||||
|
Backup::new(&templates[&version], &mut conn)
|
||||||
|
.unwrap()
|
||||||
|
.run_to_completion(128, Duration::from_millis(1), None)
|
||||||
|
.unwrap();
|
||||||
|
configure_sqlite(&conn).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_schema_v52(path: &Path) {
|
fn prepare_schema_v52(path: &Path) {
|
||||||
let conn = Connection::open(path).unwrap();
|
prepare_retained_schema(path, 52);
|
||||||
configure_sqlite(&conn).unwrap();
|
}
|
||||||
|
|
||||||
|
fn build_schema_v52(conn: &Connection) {
|
||||||
|
configure_sqlite(conn).unwrap();
|
||||||
|
// Batch fixture DDL only, never the production migration under test.
|
||||||
|
// Commit before migration calls and foreign_keys changes below.
|
||||||
|
let fixture = conn.unchecked_transaction().unwrap();
|
||||||
// Retained fixtures start from genuine pre-tracking Ticket DDL; current
|
// Retained fixtures start from genuine pre-tracking Ticket DDL; current
|
||||||
// component 11 would introduce assignment objects before frozen host 85.
|
// component 11 would introduce assignment objects before frozen host 85.
|
||||||
conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql"))
|
conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','fixture');").unwrap();
|
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','fixture');").unwrap();
|
||||||
|
fixture.commit().unwrap();
|
||||||
merge_request::migrate(&conn).unwrap();
|
merge_request::migrate(&conn).unwrap();
|
||||||
conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")
|
conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let fixture = conn.unchecked_transaction().unwrap();
|
||||||
create_frozen_schema_v85(&conn).unwrap();
|
create_frozen_schema_v85(&conn).unwrap();
|
||||||
downgrade_assignment_work_schema(&conn);
|
downgrade_assignment_work_schema(&conn);
|
||||||
downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap();
|
downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap();
|
||||||
|
fixture.commit().unwrap();
|
||||||
conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;")
|
conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let fixture = conn.unchecked_transaction().unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
DROP TABLE workdir_create_credential_revision_retentions;
|
DROP TABLE workdir_create_credential_revision_retentions;
|
||||||
@@ -17598,12 +17665,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
fixture.commit().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) {
|
fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) {
|
||||||
prepare_schema_v52(path);
|
prepare_schema_v52(path);
|
||||||
let conn = Connection::open(path).unwrap();
|
let conn = Connection::open(path).unwrap();
|
||||||
configure_sqlite(&conn).unwrap();
|
configure_sqlite(&conn).unwrap();
|
||||||
|
let fixture = conn.unchecked_transaction().unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
DROP TABLE workspace_runtime_binding_audit;
|
DROP TABLE workspace_runtime_binding_audit;
|
||||||
@@ -17654,6 +17723,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
fixture.commit().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -18487,10 +18557,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(conn);
|
migrate_workspace_runtime_bindings_v50_to_v51(&conn).unwrap();
|
||||||
|
assert_eq!(current_schema_version(&conn).unwrap(), 51);
|
||||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
|
||||||
let conn = Connection::open(&path).unwrap();
|
|
||||||
let binding_workspaces = {
|
let binding_workspaces = {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -18525,7 +18593,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
let path = temp.path().join("server.db");
|
let path = temp.path().join("server.db");
|
||||||
prepare_schema_v50(&path, None);
|
prepare_schema_v50(&path, None);
|
||||||
|
|
||||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
configure_sqlite(&conn).unwrap();
|
||||||
|
let plan = workspace_schema_migration_plan(&conn).unwrap();
|
||||||
assert_eq!(plan.current_schema_version, 50);
|
assert_eq!(plan.current_schema_version, 50);
|
||||||
assert_eq!(plan.target_schema_version, LATEST_SCHEMA_VERSION);
|
assert_eq!(plan.target_schema_version, LATEST_SCHEMA_VERSION);
|
||||||
|
|
||||||
@@ -18535,12 +18605,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
assert!(!table_exists(&unchanged, "workspace_runtime_bindings").unwrap());
|
assert!(!table_exists(&unchanged, "workspace_runtime_bindings").unwrap());
|
||||||
drop(unchanged);
|
drop(unchanged);
|
||||||
|
|
||||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
migrate_workspace_runtime_bindings_v50_to_v51(&conn).unwrap();
|
||||||
|
drop(conn);
|
||||||
let migrated = Connection::open(&path).unwrap();
|
let migrated = Connection::open(&path).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(current_schema_version(&migrated).unwrap(), 51);
|
||||||
current_schema_version(&migrated).unwrap(),
|
|
||||||
LATEST_SCHEMA_VERSION
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
migrated
|
migrated
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -18564,7 +18632,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_v52_migrates_deletion_operations_and_normalizes_incomplete_reservations() {
|
fn schema_v52_migrates_deletion_operations_and_normalizes_incomplete_reservations() -> Result<()>
|
||||||
|
{
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let path = temp.path().join("server.db");
|
let path = temp.path().join("server.db");
|
||||||
prepare_schema_v52(&path);
|
prepare_schema_v52(&path);
|
||||||
@@ -18590,59 +18659,51 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
);",
|
);",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(conn);
|
configure_sqlite(&conn)?;
|
||||||
|
migrate_workspace_deletion_v52_to_v53(&conn)?;
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
assert_eq!(current_schema_version(&conn)?, 53);
|
||||||
store
|
verify_workspace_deletion_schema(&conn)?;
|
||||||
.with_conn(|conn| {
|
let reservation_state: String = conn.query_row(
|
||||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
"SELECT state FROM worker_create_reservations WHERE workspace_id='workspace-a' AND allocation_key='allocation'",
|
||||||
verify_workspace_deletion_schema(conn)?;
|
[],
|
||||||
let reservation_state: String = conn.query_row(
|
|row| row.get(0),
|
||||||
"SELECT state FROM worker_create_reservations WHERE workspace_id='workspace-a' AND allocation_key='allocation'",
|
)?;
|
||||||
[],
|
assert_eq!(reservation_state, "created");
|
||||||
|row| row.get(0),
|
let reservation_memory = conn.query_row(
|
||||||
)?;
|
"SELECT request_fingerprint, memory_language
|
||||||
assert_eq!(reservation_state, "created");
|
FROM worker_create_reservations
|
||||||
let reservation_memory = conn.query_row(
|
WHERE workspace_id='workspace-a' AND allocation_key='allocation'",
|
||||||
"SELECT request_fingerprint, memory_language
|
[],
|
||||||
FROM worker_create_reservations
|
|row| {
|
||||||
WHERE workspace_id='workspace-a' AND allocation_key='allocation'",
|
Ok((
|
||||||
[],
|
row.get::<_, Option<String>>(0)?,
|
||||||
|row| {
|
row.get::<_, Option<String>>(1)?,
|
||||||
Ok((
|
))
|
||||||
row.get::<_, Option<String>>(0)?,
|
},
|
||||||
row.get::<_, Option<String>>(1)?,
|
)?;
|
||||||
))
|
assert_eq!(reservation_memory, (None, None));
|
||||||
},
|
let complete_memory = conn.query_row(
|
||||||
)?;
|
"SELECT request_fingerprint, memory_language
|
||||||
assert_eq!(reservation_memory, (None, None));
|
FROM worker_create_reservations
|
||||||
let complete_memory = conn.query_row(
|
WHERE workspace_id='workspace-a' AND allocation_key='allocation-complete'",
|
||||||
"SELECT request_fingerprint, memory_language
|
[],
|
||||||
FROM worker_create_reservations
|
|row| {
|
||||||
WHERE workspace_id='workspace-a' AND allocation_key='allocation-complete'",
|
Ok((
|
||||||
[],
|
row.get::<_, Option<String>>(0)?,
|
||||||
|row| {
|
row.get::<_, Option<String>>(1)?,
|
||||||
Ok((
|
))
|
||||||
row.get::<_, Option<String>>(0)?,
|
},
|
||||||
row.get::<_, Option<String>>(1)?,
|
)?;
|
||||||
))
|
assert_eq!(
|
||||||
},
|
complete_memory,
|
||||||
)?;
|
(Some("request-complete".to_string()), Some("ja".to_string()))
|
||||||
assert_eq!(
|
);
|
||||||
complete_memory,
|
let violations: i64 =
|
||||||
(
|
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||||
Some("request-complete".to_string()),
|
row.get(0)
|
||||||
Some("ja".to_string())
|
})?;
|
||||||
)
|
assert_eq!(violations, 0);
|
||||||
);
|
Ok(())
|
||||||
let violations: i64 =
|
|
||||||
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
|
||||||
row.get(0)
|
|
||||||
})?;
|
|
||||||
assert_eq!(violations, 0);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -18718,7 +18779,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
fn runtime_binding_identity_and_trust_uniqueness_are_workspace_scoped() {
|
fn runtime_binding_identity_and_trust_uniqueness_are_workspace_scoped() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let path = temp.path().join("server.db");
|
let path = temp.path().join("server.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
@@ -18870,7 +18931,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
fn workspace_runtime_connection_persists_without_authentication_evidence() {
|
fn workspace_runtime_connection_persists_without_authentication_evidence() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let path = temp.path().join("server.db");
|
let path = temp.path().join("server.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
@@ -19169,7 +19230,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_binding_key_mutations_are_identity_guarded_idempotent_and_audited() {
|
fn runtime_binding_key_mutations_are_identity_guarded_idempotent_and_audited() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
@@ -19307,7 +19368,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embedded_runtime_binding_can_explicitly_rotate_restart_identity() {
|
fn embedded_runtime_binding_can_explicitly_rotate_restart_identity() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
@@ -19419,7 +19480,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
async fn workspace_schema_rejects_cross_workspace_ticket_repository_reference_at_write_time() {
|
async fn workspace_schema_rejects_cross_workspace_ticket_repository_reference_at_write_time() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let database_path = dir.path().join("workspace.sqlite");
|
let database_path = dir.path().join("workspace.sqlite");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&database_path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&database_path).unwrap();
|
||||||
for workspace_id in ["workspace-a", "workspace-b"] {
|
for workspace_id in ["workspace-a", "workspace-b"] {
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
@@ -19513,7 +19574,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
async fn workspace_display_name_update_is_timestamp_guarded_and_preserves_identity() {
|
async fn workspace_display_name_update_is_timestamp_guarded_and_preserves_identity() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let database_path = dir.path().join("server.db");
|
let database_path = dir.path().join("server.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&database_path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&database_path).unwrap();
|
||||||
let record = WorkspaceRecord {
|
let record = WorkspaceRecord {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
owner_account_id: "owner-account".to_string(),
|
owner_account_id: "owner-account".to_string(),
|
||||||
@@ -19566,7 +19627,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn objective_creation_allocates_and_resolves_workspace_resource_key() {
|
async fn objective_creation_allocates_and_resolves_workspace_resource_key() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(dir.path().join("server.db")).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "workspace-a".into(),
|
workspace_id: "workspace-a".into(),
|
||||||
@@ -19612,7 +19673,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_create_reservation_allocates_uuid_before_runtime_and_replays_exact_input() {
|
async fn worker_create_reservation_allocates_uuid_before_runtime_and_replays_exact_input() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(dir.path().join("server.db")).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
@@ -19916,7 +19977,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
async fn worker_singleton_restart_retry_replays_exact_workdir_intent_and_rejects_changes() {
|
async fn worker_singleton_restart_retry_replays_exact_workdir_intent_and_rejects_changes() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let database = dir.path().join("server.db");
|
let database = dir.path().join("server.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&database).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
@@ -20020,7 +20081,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
async fn worker_singleton_ownership_is_workspace_scoped_durable_and_fences_old_generations() {
|
async fn worker_singleton_ownership_is_workspace_scoped_durable_and_fences_old_generations() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let database = dir.path().join("server.db");
|
let database = dir.path().join("server.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&database).unwrap();
|
||||||
for workspace_id in ["workspace-a", "workspace-b"] {
|
for workspace_id in ["workspace-a", "workspace-b"] {
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
@@ -20146,7 +20207,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
async fn concurrent_worker_singleton_reservations_have_one_winner() {
|
async fn concurrent_worker_singleton_reservations_have_one_winner() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let database = dir.path().join("server.db");
|
let database = dir.path().join("server.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&database).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
@@ -20202,7 +20263,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_flow_sources_pin_content_and_builtins_stay_resources() {
|
async fn workspace_flow_sources_pin_content_and_builtins_stay_resources() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(dir.path().join("server.db")).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
@@ -21144,7 +21205,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
// boundary under test. Targets need not be materialized by a Runtime.
|
// boundary under test. Targets need not be materialized by a Runtime.
|
||||||
async fn manual_worker_start_store() -> (tempfile::TempDir, SqliteWorkspaceStore) {
|
async fn manual_worker_start_store() -> (tempfile::TempDir, SqliteWorkspaceStore) {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let store = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap();
|
let store =
|
||||||
|
SqliteWorkspaceStore::open_api_fixture(dir.path().join("workspace.db")).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
@@ -21879,7 +21941,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
use ticket::TicketBackend;
|
use ticket::TicketBackend;
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("workspace.db");
|
let path = dir.path().join("workspace.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
@@ -22007,7 +22069,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() {
|
async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let db = dir.path().join("server.db");
|
let db = dir.path().join("server.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&db).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&db).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
@@ -22322,7 +22384,7 @@ INSERT INTO worker_registry (
|
|||||||
async fn role_assignment_routes_and_manual_start_are_state_fenced_atomically() {
|
async fn role_assignment_routes_and_manual_start_are_state_fenced_atomically() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let db_path = dir.path().join("workspace.db");
|
let db_path = dir.path().join("workspace.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&db_path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&db_path).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "workspace-role".to_string(),
|
workspace_id: "workspace-role".to_string(),
|
||||||
@@ -22732,7 +22794,7 @@ INSERT INTO worker_registry (
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_bootstrap_validates_key_and_rejects_duplicate_workspace() {
|
async fn workspace_bootstrap_validates_key_and_rejects_duplicate_workspace() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store
|
store
|
||||||
.upsert_account(&AccountRecord {
|
.upsert_account(&AccountRecord {
|
||||||
account_id: "owner-account".to_string(),
|
account_id: "owner-account".to_string(),
|
||||||
@@ -22864,7 +22926,7 @@ INSERT INTO worker_registry (
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn repository_records_round_trip() {
|
async fn repository_records_round_trip() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION);
|
assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION);
|
||||||
let workspace = WorkspaceRecord {
|
let workspace = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
@@ -22945,7 +23007,7 @@ INSERT INTO worker_registry (
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn memory_authority_records_round_trip_and_close_staging() {
|
async fn memory_authority_records_round_trip_and_close_staging() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION);
|
assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION);
|
||||||
let workspace = WorkspaceRecord {
|
let workspace = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
@@ -23020,7 +23082,7 @@ INSERT INTO worker_registry (
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn legacy_memory_reset_is_atomic_scoped_idempotent_and_exact() {
|
async fn legacy_memory_reset_is_atomic_scoped_idempotent_and_exact() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
for (workspace_id, display_name) in [
|
for (workspace_id, display_name) in [
|
||||||
("local-dev", "Local Dev"),
|
("local-dev", "Local Dev"),
|
||||||
("other-workspace", "Other Workspace"),
|
("other-workspace", "Other Workspace"),
|
||||||
@@ -23187,7 +23249,7 @@ INSERT INTO worker_registry (
|
|||||||
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
|
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let db = temp.path().join("workspace.db");
|
let db = temp.path().join("workspace.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&db).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&db).unwrap();
|
||||||
let workspace = WorkspaceRecord {
|
let workspace = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
owner_account_id: "owner-account".to_string(),
|
owner_account_id: "owner-account".to_string(),
|
||||||
@@ -23604,7 +23666,8 @@ INSERT INTO worker_registry (
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_projection_preserves_equal_local_ids_from_distinct_runtimes() {
|
async fn worker_projection_preserves_equal_local_ids_from_distinct_runtimes() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = SqliteWorkspaceStore::open_for_test(temp.path().join("workspace.db")).unwrap();
|
let store =
|
||||||
|
SqliteWorkspaceStore::open_api_fixture(temp.path().join("workspace.db")).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
@@ -23730,7 +23793,8 @@ INSERT INTO worker_registry (
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_projection_reconciles_snapshots_and_preserves_unavailable_lifecycle() {
|
async fn worker_projection_reconciles_snapshots_and_preserves_unavailable_lifecycle() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = SqliteWorkspaceStore::open_for_test(temp.path().join("workspace.db")).unwrap();
|
let store =
|
||||||
|
SqliteWorkspaceStore::open_api_fixture(temp.path().join("workspace.db")).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
@@ -23905,7 +23969,8 @@ INSERT INTO worker_registry (
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn late_runtime_event_does_not_recreate_retention_deleted_catalog_worker() {
|
async fn late_runtime_event_does_not_recreate_retention_deleted_catalog_worker() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = SqliteWorkspaceStore::open_for_test(temp.path().join("workspace.db")).unwrap();
|
let store =
|
||||||
|
SqliteWorkspaceStore::open_api_fixture(temp.path().join("workspace.db")).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
@@ -24000,7 +24065,7 @@ INSERT INTO worker_registry (
|
|||||||
async fn worker_control_grants_are_idempotent_scoped_and_support_internal_invalidation() {
|
async fn worker_control_grants_are_idempotent_scoped_and_support_internal_invalidation() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let database = dir.path().join("control-grants.db");
|
let database = dir.path().join("control-grants.db");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&database).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "workspace-control".to_string(),
|
workspace_id: "workspace-control".to_string(),
|
||||||
@@ -24128,7 +24193,7 @@ INSERT INTO worker_registry (
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn account_and_login_records_round_trip() {
|
async fn account_and_login_records_round_trip() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION);
|
assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION);
|
||||||
let now = "2026-07-22T00:00:00Z".to_string();
|
let now = "2026-07-22T00:00:00Z".to_string();
|
||||||
let account = AccountRecord {
|
let account = AccountRecord {
|
||||||
@@ -24488,7 +24553,7 @@ INSERT INTO worker_registry (
|
|||||||
async fn backend_job_resource_reserve_converges_across_connections_without_changing_input() {
|
async fn backend_job_resource_reserve_converges_across_connections_without_changing_input() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("resource-jobs.sqlite");
|
let path = dir.path().join("resource-jobs.sqlite");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
seed_resource_job_workspace(&store).await;
|
seed_resource_job_workspace(&store).await;
|
||||||
let other = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let other = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||||
let requests = [
|
let requests = [
|
||||||
@@ -24606,7 +24671,7 @@ INSERT INTO worker_registry (
|
|||||||
async fn backend_job_resource_retry_and_unknown_are_fenced_by_durable_cleanup() {
|
async fn backend_job_resource_retry_and_unknown_are_fenced_by_durable_cleanup() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("cleanup-jobs.sqlite");
|
let path = dir.path().join("cleanup-jobs.sqlite");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
seed_resource_job_workspace(&store).await;
|
seed_resource_job_workspace(&store).await;
|
||||||
let request = resource_job_request("failed", "subject-a", "candidate-1");
|
let request = resource_job_request("failed", "subject-a", "candidate-1");
|
||||||
let first = store
|
let first = store
|
||||||
@@ -24772,7 +24837,7 @@ INSERT INTO worker_registry (
|
|||||||
async fn backend_job_resource_retry_races_new_intent_atomically_and_remains_bounded() {
|
async fn backend_job_resource_retry_races_new_intent_atomically_and_remains_bounded() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("retry-race.sqlite");
|
let path = dir.path().join("retry-race.sqlite");
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
seed_resource_job_workspace(&store).await;
|
seed_resource_job_workspace(&store).await;
|
||||||
// Generic resources use the same common reservation API without domain grants.
|
// Generic resources use the same common reservation API without domain grants.
|
||||||
let mut request = resource_job_request("generic", "subject-a", "candidate-1");
|
let mut request = resource_job_request("generic", "subject-a", "candidate-1");
|
||||||
@@ -24881,7 +24946,7 @@ INSERT INTO worker_registry (
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn backend_job_all_older_attempt_cleanups_fence_retry_and_resource_release() {
|
async fn backend_job_all_older_attempt_cleanups_fence_retry_and_resource_release() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
seed_resource_job_workspace(&store).await;
|
seed_resource_job_workspace(&store).await;
|
||||||
let mut request = resource_job_request("legacy-overlap", "subject-a", "candidate-1");
|
let mut request = resource_job_request("legacy-overlap", "subject-a", "candidate-1");
|
||||||
request.limits.max_attempts = 3;
|
request.limits.max_attempts = 3;
|
||||||
@@ -25005,7 +25070,7 @@ INSERT INTO worker_registry (
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn backend_job_empty_batch_surface_refresh_locks_subject_and_defers_candidates() {
|
async fn backend_job_empty_batch_surface_refresh_locks_subject_and_defers_candidates() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
seed_resource_job_workspace(&store).await;
|
seed_resource_job_workspace(&store).await;
|
||||||
for surface_state in ["ungenerated", "stale", "failed"] {
|
for surface_state in ["ungenerated", "stale", "failed"] {
|
||||||
let subject = format!("subject-{surface_state}");
|
let subject = format!("subject-{surface_state}");
|
||||||
@@ -25125,7 +25190,7 @@ INSERT INTO worker_registry (
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn backend_job_completed_resource_releases_only_after_worker_cleanup() {
|
async fn backend_job_completed_resource_releases_only_after_worker_cleanup() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
seed_resource_job_workspace(&store).await;
|
seed_resource_job_workspace(&store).await;
|
||||||
let request = resource_job_request("completed", "subject-a", "candidate-1");
|
let request = resource_job_request("completed", "subject-a", "candidate-1");
|
||||||
let first = store
|
let first = store
|
||||||
@@ -25179,7 +25244,7 @@ INSERT INTO worker_registry (
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn backend_jobs_fence_results_project_ownership_and_bound_retries() {
|
async fn backend_jobs_fence_results_project_ownership_and_bound_retries() {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
let workspace_id = "workspace-job";
|
let workspace_id = "workspace-job";
|
||||||
store
|
store
|
||||||
.upsert_account(&AccountRecord {
|
.upsert_account(&AccountRecord {
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//! Ordinary store/API fixtures copy a verified empty schema into independent real DBs.
|
||||||
|
//! Each case owns its connections and executors, with production SQLite settings.
|
||||||
|
//! Additional connections within fresh API setup reuse its verified schema. Migration,
|
||||||
|
//! fresh-creation, drift detection and persisted-case reopen tests use uncached constructors.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl SqliteWorkspaceStore {
|
||||||
|
pub(crate) fn open_api_fixture(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
if path.exists() {
|
||||||
|
// Restart tests must reopen their persisted database, not reset it.
|
||||||
|
return Self::open_for_test(path);
|
||||||
|
}
|
||||||
|
Self::from_fixture_connection(Connection::open(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn in_memory_fixture() -> Result<Self> {
|
||||||
|
Self::from_fixture_connection(Connection::open_in_memory()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the fresh API fixture may use this after copying its verified template.
|
||||||
|
// Open a distinct connection without replacing the DB or sharing a transaction.
|
||||||
|
pub(crate) fn connect_prepared_fixture(path: &Path) -> Result<Self> {
|
||||||
|
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
|
||||||
|
configure_sqlite(&conn)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
|
db_executor: crate::db_executor::DbExecutor::new(
|
||||||
|
crate::db_executor::DbExecutorLimits::default(),
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_fixture_connection(mut conn: Connection) -> Result<Self> {
|
||||||
|
static TEMPLATE: std::sync::OnceLock<Mutex<Option<Connection>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
{
|
||||||
|
// Only initialization/copy is serialized, never a test's execution.
|
||||||
|
let mut template = TEMPLATE
|
||||||
|
.get_or_init(|| Mutex::new(None))
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| Error::Store("API fixture template lock poisoned".into()))?;
|
||||||
|
if template.is_none() {
|
||||||
|
let source = Connection::open_in_memory()?;
|
||||||
|
prepare_connection(&source)?;
|
||||||
|
*template = Some(source);
|
||||||
|
}
|
||||||
|
Backup::new(template.as_ref().unwrap(), &mut conn)?.run_to_completion(
|
||||||
|
128,
|
||||||
|
Duration::from_millis(1),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
// Backup copies the already verified schema, not connection-local PRAGMAs.
|
||||||
|
// Existing DBs above still pass through full production startup checks.
|
||||||
|
configure_sqlite(&conn)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
|
db_executor: crate::db_executor::DbExecutor::new(
|
||||||
|
crate::db_executor::DbExecutorLimits::default(),
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepared_fixture_connections_observe_only_committed_writes_and_keep_foreign_keys() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("connections.db");
|
||||||
|
assert!(SqliteWorkspaceStore::connect_prepared_fixture(&path).is_err());
|
||||||
|
assert!(!path.exists());
|
||||||
|
let writer = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
|
let reader = SqliteWorkspaceStore::connect_prepared_fixture(&path).unwrap();
|
||||||
|
writer.with_conn(|conn| {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','1','1')", [])?;
|
||||||
|
reader.with_conn(|other| {
|
||||||
|
assert!(other.is_autocommit());
|
||||||
|
assert_eq!(other.query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get::<_, i64>(0))?, 0);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
reader.with_conn(|conn| {
|
||||||
|
assert_eq!(conn.query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get::<_, i64>(0))?, 1);
|
||||||
|
assert_eq!(conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))?, "wal");
|
||||||
|
assert!(conn.execute("INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('invalid','missing','Invalid','active','1','1')", []).is_err());
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_fixture_copies_fresh_schema_and_isolates_data_and_ddl() {
|
||||||
|
fn schema(store: &SqliteWorkspaceStore) -> Vec<(String, String, Option<String>)> {
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
let mut statement =
|
||||||
|
conn.prepare("SELECT type, name, sql FROM sqlite_schema ORDER BY type, name")?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||||
|
let first = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
|
assert_eq!(schema(&first), schema(&fresh));
|
||||||
|
first.with_conn(|conn| {
|
||||||
|
assert_eq!(conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?, 1);
|
||||||
|
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','1','1'); DROP TRIGGER ticket_assignment_ticket_parent_tombstone;")?;
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
let second = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
|
assert_eq!(schema(&second), schema(&fresh));
|
||||||
|
second.with_conn(|conn| {
|
||||||
|
assert_eq!(conn.query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get::<_, i64>(0))?, 0);
|
||||||
|
assert!(conn.execute("INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('invalid','missing','Invalid','active','1','1')", []).is_err());
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_fixture_reopen_rejects_schema_drift_instead_of_replacing_the_database() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("drift.db");
|
||||||
|
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute_batch("DROP TRIGGER ticket_assignment_ticket_parent_tombstone;")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
let error = SqliteWorkspaceStore::open_api_fixture(&path)
|
||||||
|
.err()
|
||||||
|
.expect("reopen must verify existing schema");
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("ticket_assignment_ticket_parent_tombstone")
|
||||||
|
);
|
||||||
|
let conn = Connection::open(path).unwrap();
|
||||||
|
assert_eq!(conn.query_row("SELECT COUNT(*) FROM sqlite_schema WHERE name='ticket_assignment_ticket_parent_tombstone'", [], |row| row.get::<_, i64>(0)).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_fixture_databases_isolate_writes_and_reopen_persisted_rows() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let handles: Vec<_> = ["first", "second"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let path = temp.path().join(format!("{name}.db"));
|
||||||
|
scope.spawn(move || {
|
||||||
|
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
|
store.with_conn(|conn| {
|
||||||
|
assert_eq!(conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?, 1);
|
||||||
|
assert_eq!(conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))?, "wal");
|
||||||
|
conn.execute("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner',?1,'1','1')", [name])?;
|
||||||
|
assert!(conn.execute("INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('invalid','missing','Invalid','active','1','1')", []).is_err());
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
drop(store);
|
||||||
|
let reopened = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||||
|
reopened.with_conn(|conn| {
|
||||||
|
assert_eq!(conn.query_row("SELECT display_name FROM accounts WHERE account_id='owner'", [], |row| row.get::<_, String>(0))?, name);
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for handle in handles {
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let pristine = SqliteWorkspaceStore::open_api_fixture(temp.path().join("pristine.db")).unwrap();
|
||||||
|
pristine
|
||||||
|
.with_conn(|conn| {
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM accounts", [], |row| row
|
||||||
|
.get::<_, i64>(0))?,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
@@ -286,6 +286,94 @@ pub(crate) fn render_result(acceptance: &BackendJobResultAcceptance) -> Result<S
|
|||||||
Ok(content)
|
Ok(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Supplied model outputs exercise host validation/rendering, not LLM judgment.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_support {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) struct FakeTicketCheckerModel;
|
||||||
|
|
||||||
|
impl FakeTicketCheckerModel {
|
||||||
|
pub(crate) fn respond(request: &BackendJobRequest) -> Value {
|
||||||
|
let input = parse_input(&request.input).unwrap();
|
||||||
|
assert!(request.instruction.contains("writer_scope"));
|
||||||
|
assert!(request.instruction.contains("internal_inconsistency"));
|
||||||
|
assert!(request.instruction.contains("ambiguous_boundary"));
|
||||||
|
assert!(
|
||||||
|
request
|
||||||
|
.instruction
|
||||||
|
.contains("Production deployment requires approval")
|
||||||
|
);
|
||||||
|
assert!(request.instruction.contains("background, quotations"));
|
||||||
|
if input
|
||||||
|
.body
|
||||||
|
.contains("This time I only create the Ticket; I will not implement it.")
|
||||||
|
&& !input.body.starts_with("Background bug example:")
|
||||||
|
{
|
||||||
|
serde_json::json!({"findings": [{
|
||||||
|
"category": "writer_scope",
|
||||||
|
"quote": "I will not implement it",
|
||||||
|
"reason": "The active requirement records the writer's own implementation boundary.",
|
||||||
|
"suggestion": "Remove the writer-specific action boundary."
|
||||||
|
}]})
|
||||||
|
} else if input.body.contains("The rollout region is undecided.")
|
||||||
|
&& input.body.contains("The rollout region is us-east-1.")
|
||||||
|
{
|
||||||
|
serde_json::json!({"findings": [{
|
||||||
|
"category": "internal_inconsistency",
|
||||||
|
"quote": "The rollout region is undecided.",
|
||||||
|
"reason": "The same active text also settles the rollout region as us-east-1.",
|
||||||
|
"suggestion": "Choose either the unresolved or settled rollout-region statement."
|
||||||
|
}]})
|
||||||
|
} else if input.body == "Implementation stops after Ticket creation." {
|
||||||
|
serde_json::json!({"findings": [{
|
||||||
|
"category": "ambiguous_boundary",
|
||||||
|
"quote": "Implementation stops after Ticket creation.",
|
||||||
|
"reason": "The text alone does not establish whether this is a delivery constraint or the writer's current action boundary.",
|
||||||
|
"suggestion": "Confirm whether this limits the product workflow or only the current writer."
|
||||||
|
}]})
|
||||||
|
} else {
|
||||||
|
serde_json::json!({"findings": []})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Case {
|
||||||
|
pub(crate) name: &'static str,
|
||||||
|
pub(crate) body: &'static str,
|
||||||
|
pub(crate) advisory_quote: Option<&'static str>,
|
||||||
|
}
|
||||||
|
pub(crate) fn cases() -> Vec<Case> {
|
||||||
|
vec![
|
||||||
|
Case {
|
||||||
|
name: "writer-scope-positive",
|
||||||
|
body: "This time I only create the Ticket; I will not implement it.",
|
||||||
|
advisory_quote: Some("I will not implement it"),
|
||||||
|
},
|
||||||
|
Case {
|
||||||
|
name: "production-approval-negative",
|
||||||
|
body: "Production deployment requires approval.",
|
||||||
|
advisory_quote: None,
|
||||||
|
},
|
||||||
|
Case {
|
||||||
|
name: "quoted-background-negative",
|
||||||
|
body: "Background bug example: a generated Ticket once said “I will not implement it.” Do not reproduce that sentence in generated requirements.",
|
||||||
|
advisory_quote: None,
|
||||||
|
},
|
||||||
|
Case {
|
||||||
|
name: "unresolved-versus-settled-positive",
|
||||||
|
body: "The rollout region is undecided. The rollout region is us-east-1.",
|
||||||
|
advisory_quote: Some("The rollout region is undecided."),
|
||||||
|
},
|
||||||
|
Case {
|
||||||
|
name: "ambiguous-boundary-confirmation",
|
||||||
|
body: "Implementation stops after Ticket creation.",
|
||||||
|
advisory_quote: Some("Implementation stops after Ticket creation."),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -301,16 +389,144 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ticket_fixture() -> ticket::Ticket {
|
||||||
|
ticket::Ticket {
|
||||||
|
meta: ticket::TicketMeta {
|
||||||
|
id: "ticket-internal".into(),
|
||||||
|
resource_key: Some("T-42".into()),
|
||||||
|
slug: "check-this-content".into(),
|
||||||
|
title: "Check this content".into(),
|
||||||
|
status: ticket::ExtensibleTicketStatus::Open,
|
||||||
|
kind: "task".into(),
|
||||||
|
priority: "P2".into(),
|
||||||
|
labels: vec![],
|
||||||
|
created_at: None,
|
||||||
|
created_by: None,
|
||||||
|
updated_at: None,
|
||||||
|
assignee: None,
|
||||||
|
readiness: None,
|
||||||
|
risk_flags: vec![],
|
||||||
|
workflow_state: ticket::TicketState::Open,
|
||||||
|
workflow_state_explicit: true,
|
||||||
|
queued_by: None,
|
||||||
|
queued_at: None,
|
||||||
|
targets: vec![],
|
||||||
|
raw: Default::default(),
|
||||||
|
},
|
||||||
|
document: ticket::TicketDocument {
|
||||||
|
body: ticket::MarkdownText::new("Check the saved content."),
|
||||||
|
raw_frontmatter: Default::default(),
|
||||||
|
},
|
||||||
|
tags: vec![],
|
||||||
|
events: vec![],
|
||||||
|
artifacts: vec![],
|
||||||
|
relations: Default::default(),
|
||||||
|
resolution: None,
|
||||||
|
closure_operation_key: None,
|
||||||
|
current_closure: None,
|
||||||
|
case_history: vec![],
|
||||||
|
legacy_workflow_snapshot: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn completed_acceptance(
|
||||||
|
request: BackendJobRequest,
|
||||||
|
result: Value,
|
||||||
|
) -> BackendJobResultAcceptance {
|
||||||
|
use crate::backend_job::{
|
||||||
|
BackendJobAttemptRecord, BackendJobAttemptState, BackendJobRecord, BackendJobState,
|
||||||
|
};
|
||||||
|
let digest = crate::backend_job::result_digest(&result, request.limits.max_result_bytes)
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
let attempt = BackendJobAttemptRecord {
|
||||||
|
workspace_id: "checker-unit".into(),
|
||||||
|
job_id: request.job_id.clone(),
|
||||||
|
attempt_id: "checker-attempt".into(),
|
||||||
|
attempt: 1,
|
||||||
|
input_digest: request.input_digest().unwrap(),
|
||||||
|
state: BackendJobAttemptState::Completed,
|
||||||
|
worker: Some(RuntimeWorkerRef::new("runtime", "checker")),
|
||||||
|
runtime_run_id: Some("run".into()),
|
||||||
|
dispatched_at: Some("1".into()),
|
||||||
|
deadline_at: "3".into(),
|
||||||
|
result: Some(result.clone()),
|
||||||
|
result_digest: Some(digest.clone()),
|
||||||
|
failure_category: None,
|
||||||
|
failure_detail: None,
|
||||||
|
worker_cleanup_state: None,
|
||||||
|
worker_cleanup_failure_category: None,
|
||||||
|
worker_cleanup_failure_detail: None,
|
||||||
|
worker_cleanup_updated_at: None,
|
||||||
|
worker_cleanup_completed_at: None,
|
||||||
|
created_at: "1".into(),
|
||||||
|
updated_at: "2".into(),
|
||||||
|
completed_at: Some("2".into()),
|
||||||
|
};
|
||||||
|
BackendJobResultAcceptance {
|
||||||
|
job: BackendJobRecord {
|
||||||
|
workspace_id: "checker-unit".into(),
|
||||||
|
intent_fingerprint: request.fingerprint().unwrap(),
|
||||||
|
request,
|
||||||
|
state: BackendJobState::Completed,
|
||||||
|
current_attempt: 1,
|
||||||
|
result: Some(result),
|
||||||
|
result_digest: Some(digest),
|
||||||
|
failure_category: None,
|
||||||
|
failure_detail: None,
|
||||||
|
created_at: "1".into(),
|
||||||
|
updated_at: "2".into(),
|
||||||
|
completed_at: Some("2".into()),
|
||||||
|
},
|
||||||
|
attempt,
|
||||||
|
replayed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supplied_model_classifications_validate_and_render_advisories_or_no_findings() {
|
||||||
|
let instruction = worker::PromptCatalog::builtins_only()
|
||||||
|
.unwrap()
|
||||||
|
.ticket_item_checker()
|
||||||
|
.unwrap();
|
||||||
|
for case in test_support::cases() {
|
||||||
|
let mut ticket = ticket_fixture();
|
||||||
|
ticket.meta.title = format!("Boundary case: {}", case.name);
|
||||||
|
ticket.document.body = ticket::MarkdownText::new(case.body);
|
||||||
|
let request = request(
|
||||||
|
&ticket,
|
||||||
|
TicketItemCheckEdit::Create,
|
||||||
|
RuntimeWorkerRef::new("runtime", "source"),
|
||||||
|
instruction.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = test_support::FakeTicketCheckerModel::respond(&request);
|
||||||
|
let checked = validate_result(&request.input, &result).unwrap();
|
||||||
|
let acceptance = completed_acceptance(request, result);
|
||||||
|
match (
|
||||||
|
case.advisory_quote,
|
||||||
|
result_facts(&acceptance, &ticket).unwrap(),
|
||||||
|
) {
|
||||||
|
(Some(quote), TicketItemCheckFacts::Findings(advisory)) => {
|
||||||
|
assert_eq!(checked.findings.len(), 1, "{}", case.name);
|
||||||
|
assert!(
|
||||||
|
advisory.contains("[Ticket item checker advisory]"),
|
||||||
|
"{}",
|
||||||
|
case.name
|
||||||
|
);
|
||||||
|
assert!(advisory.contains(quote), "{}", case.name);
|
||||||
|
}
|
||||||
|
(None, TicketItemCheckFacts::NoFindings) => {
|
||||||
|
assert!(checked.findings.is_empty(), "{}", case.name);
|
||||||
|
}
|
||||||
|
(_, actual) => panic!("{}: unexpected classification {actual:?}", case.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn checker_job_identity_reuses_exact_intent_but_separates_edits_and_sources() {
|
fn checker_job_identity_reuses_exact_intent_but_separates_edits_and_sources() {
|
||||||
use ticket::TicketBackend;
|
let ticket = ticket_fixture();
|
||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let backend =
|
|
||||||
ticket::SqliteTicketBackend::open(dir.path().join("tickets.db"), "space").unwrap();
|
|
||||||
let (_, ticket) = backend
|
|
||||||
.create_with_snapshot(ticket::NewTicket::new("Check this content"))
|
|
||||||
.unwrap();
|
|
||||||
let source = RuntimeWorkerRef {
|
let source = RuntimeWorkerRef {
|
||||||
runtime_id: "runtime".into(),
|
runtime_id: "runtime".into(),
|
||||||
worker_id: "source".into(),
|
worker_id: "source".into(),
|
||||||
|
|||||||
@@ -939,7 +939,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn seeded_store() -> (SqliteWorkspaceStore, WorkdirRegistryRecord) {
|
async fn seeded_store() -> (SqliteWorkspaceStore, WorkdirRegistryRecord) {
|
||||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||||
store
|
store
|
||||||
.upsert_account(&AccountRecord {
|
.upsert_account(&AccountRecord {
|
||||||
account_id: "account-a".to_string(),
|
account_id: "account-a".to_string(),
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_is_atomic_and_exact_retries_converge() {
|
async fn create_is_atomic_and_exact_retries_converge() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory_fixture().unwrap());
|
||||||
let service = WorkspaceCatalogService::new(
|
let service = WorkspaceCatalogService::new(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
Arc::new(
|
Arc::new(
|
||||||
@@ -356,7 +356,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_rejects_noncanonical_repository_keys_without_persisting_a_workspace() {
|
fn create_rejects_noncanonical_repository_keys_without_persisting_a_workspace() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory_fixture().unwrap());
|
||||||
let service = WorkspaceCatalogService::new(
|
let service = WorkspaceCatalogService::new(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
Arc::new(
|
Arc::new(
|
||||||
@@ -517,7 +517,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory_fixture().unwrap());
|
||||||
let owner_account_id = owner_account(store.as_ref());
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(
|
let service = WorkspaceCatalogService::new(
|
||||||
store,
|
store,
|
||||||
@@ -549,7 +549,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repository_free_create_initializes_authority_and_concurrent_retries_converge() {
|
fn repository_free_create_initializes_authority_and_concurrent_retries_converge() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory_fixture().unwrap());
|
||||||
let owner = owner_account(store.as_ref());
|
let owner = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(
|
let service = WorkspaceCatalogService::new(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
@@ -628,7 +628,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_reads_current_repository_at_initial_key_and_never_recreates_it() {
|
fn replay_reads_current_repository_at_initial_key_and_never_recreates_it() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory_fixture().unwrap());
|
||||||
let owner = owner_account(store.as_ref());
|
let owner = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(
|
let service = WorkspaceCatalogService::new(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
@@ -756,7 +756,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_rejects_non_user_account_owners() {
|
fn create_rejects_non_user_account_owners() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory_fixture().unwrap());
|
||||||
store
|
store
|
||||||
.upsert_account(&AccountRecord {
|
.upsert_account(&AccountRecord {
|
||||||
account_id: "organization-owner".to_string(),
|
account_id: "organization-owner".to_string(),
|
||||||
@@ -794,7 +794,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn catalog_list_is_scoped_to_the_required_owner_account() {
|
fn catalog_list_is_scoped_to_the_required_owner_account() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory_fixture().unwrap());
|
||||||
let owner_a = owner_account(store.as_ref());
|
let owner_a = owner_account(store.as_ref());
|
||||||
let owner_b = "account-owner-b".to_string();
|
let owner_b = "account-owner-b".to_string();
|
||||||
store
|
store
|
||||||
@@ -862,7 +862,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
|
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory_fixture().unwrap());
|
||||||
let owner_account_id = owner_account(store.as_ref());
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(
|
let service = WorkspaceCatalogService::new(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
|
|||||||
@@ -747,7 +747,7 @@ mod tests {
|
|||||||
WorkspaceRecord,
|
WorkspaceRecord,
|
||||||
};
|
};
|
||||||
let store = std::sync::Arc::new(
|
let store = std::sync::Arc::new(
|
||||||
SqliteWorkspaceStore::in_memory_for_test()
|
SqliteWorkspaceStore::in_memory_fixture()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_db_executor(crate::db_executor::DbExecutor::new(Default::default()).unwrap()),
|
.with_db_executor(crate::db_executor::DbExecutor::new(Default::default()).unwrap()),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user