Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b284934e34 | ||
|
|
2d33321fab | ||
|
|
0975471986 | ||
|
|
214d6e21dd | ||
|
|
fca5c23345 | ||
|
|
f9b1500ba2 | ||
|
|
931bf3b27d | ||
|
|
6bb671be21 | ||
|
|
b8f2fa479c | ||
|
|
1639be9841 |
Generated
+6
@@ -6707,15 +6707,21 @@ name = "yoi-e2e"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"config-source",
|
||||
"libc",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"server-api",
|
||||
"session-store",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"tungstenite 0.29.0",
|
||||
"webauthn-authenticator-rs",
|
||||
"webauthn-rs-proto",
|
||||
"worker-runtime",
|
||||
"yoi-workspace-server",
|
||||
]
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ required-features = ["ws-server", "fs-store"]
|
||||
[features]
|
||||
default = ["ws-server", "fs-store"]
|
||||
fs-store = ["dep:fs4"]
|
||||
# Offline fixture tooling only; never enabled by the product defaults.
|
||||
test-support = ["fs-store", "ws-server"]
|
||||
http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
|
||||
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
||||
|
||||
|
||||
@@ -8,3 +8,34 @@ Manages Worker creation, stopping, and Restore, execution connections within the
|
||||
- [`execution`] — execution backends and operation-specific results.
|
||||
- [`working_directory`] — repository materialization, session resources, and safe deletion.
|
||||
- [`workspace_issuer`] — Workspace signature verification and replay prevention.
|
||||
|
||||
## Offline stopped-Worker fixtures
|
||||
|
||||
The nondefault `test-support` feature exposes `test_support::StoppedWorkerTemplate`.
|
||||
It is intentionally limited to current-schema, stopped, attachment-free artificial
|
||||
conversations with one Segment, text history, and no pending execution/activation.
|
||||
It does not add a product endpoint or change create/restore authorization.
|
||||
|
||||
1. With the source Runtime offline, call `StoppedWorkerTemplate::extract(worker_dir)`.
|
||||
The source must have its existing Runtime owner lock and identity. Extraction is
|
||||
read-only; the returned serde template excludes source auth, saved Manifest,
|
||||
request/config references, run directories, and Runtime operation history.
|
||||
2. Initialize **new** Runtime identity and Workspace trust in a fresh destination
|
||||
using normal setup. Preserve the template's Runtime and Workspace IDs.
|
||||
3. Call `template.install(&options, request, &resolved_manifest)` before starting
|
||||
the Runtime. Supply a fresh typed `CreateWorkerRequest` (including config/profile
|
||||
references and trusted memory settings) and `manifest::WorkerManifest` resolved
|
||||
for that environment. Its Worker name must equal `template.worker_name()`.
|
||||
4. Start normally and use the ordinary explicit restore operation. Installation
|
||||
itself creates no execution and leaves the Worker stopped with a fresh guard.
|
||||
|
||||
The template preserves the original Session manifest/JSONL bytes, including stable
|
||||
entry IDs, timestamps, and settled submission receipts. It rejects resumable queues
|
||||
and Feature invocation history. A saved system prompt is required for restore;
|
||||
checkpoint receipts must match the Worker's typed receipt schema. Preserved records
|
||||
must use canonical writer field shapes: unknown/duplicate fields (including nested
|
||||
fields), discarded optional fields, and opaque Feature history payloads are rejected,
|
||||
not stripped or rewritten. Portability/secret-marker checks do not replace
|
||||
review of the supplied artificial conversation or its independently verified hashes.
|
||||
The installer refuses any destination content other than initialized `auth.toml`;
|
||||
interrupted publication is fail-closed, not an overwrite/recovery facility.
|
||||
|
||||
@@ -39,6 +39,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
#[path = "test_support.rs"]
|
||||
pub mod test_support;
|
||||
|
||||
const SCHEMA_VERSION: u32 = 12;
|
||||
// Frozen source schemas: do not move their meaning when adding a migration.
|
||||
const SOURCE_SCHEMA_VERSION: u32 = 11;
|
||||
|
||||
@@ -3206,9 +3206,13 @@ mod tests {
|
||||
.await
|
||||
.expect_err("close must be terminal");
|
||||
assert!(matches!(
|
||||
closed_error,
|
||||
closed_error.classification_source(),
|
||||
workdir::WorkdirError::Unavailable(_)
|
||||
));
|
||||
assert_eq!(
|
||||
closed_error.failure_reason(),
|
||||
Some(workdir::WorkdirFailureReason::SessionClosed)
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.workdir_sessions
|
||||
|
||||
@@ -32,6 +32,8 @@ pub mod workspace_issuer;
|
||||
#[cfg(feature = "http-server")]
|
||||
pub mod workspace_request;
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use fs_store::test_support;
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
pub use management::RuntimeOptions;
|
||||
|
||||
@@ -0,0 +1,859 @@
|
||||
//! Offline support for the approved, attachment-free stopped Worker fixture.
|
||||
//!
|
||||
//! Enabled only by `test-support`. This is not an import API: extraction accepts a
|
||||
//! current-schema, settled, single-Segment aggregate; installation accepts only a
|
||||
//! fresh Runtime directory containing its newly initialized `auth.toml`. No
|
||||
//! Runtime or execution backend is constructed. Original Session bytes are never
|
||||
//! rewritten. The caller must separately review/attest the artificial conversation;
|
||||
//! structural checks and secret-marker checks cannot prove arbitrary prose safe.
|
||||
|
||||
use super::*;
|
||||
use crate::catalog::ProfileSourceArchiveSource;
|
||||
use crate::workspace_issuer::{
|
||||
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, validate_workspace_issuer_trust_records,
|
||||
};
|
||||
use session_store::{
|
||||
SegmentId, SessionId, WorkerActiveSegmentRef, WorkerMetadata, WorkerSessionStore,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "test_support_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
const TEMPLATE_VERSION: u32 = 1;
|
||||
const MAX_SESSION_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Portable fixture, intentionally without Debug (Session material is private).
|
||||
/// Fields are private and every deserialized template is validated on install.
|
||||
/// Serialize this value to retain the fixture; do not serialize its source aggregate.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StoppedWorkerTemplate {
|
||||
template_version: u32,
|
||||
runtime_schema_version: u32,
|
||||
runtime_id: String,
|
||||
workspace_id: String,
|
||||
worker_id: WorkerId,
|
||||
created_at_ms: Option<u64>,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
session_manifest: Vec<u8>,
|
||||
segment_jsonl: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SessionManifest {
|
||||
schema_version: u32,
|
||||
session_id: SessionId,
|
||||
}
|
||||
|
||||
// Read-only projection: never retain source key material in a template.
|
||||
#[derive(Deserialize)]
|
||||
struct AuthIdentity {
|
||||
identity_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SourceAuth {
|
||||
identity: AuthIdentity,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct InitializedAuth {
|
||||
identity: crate::auth::RuntimeIdentityMaterial,
|
||||
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
|
||||
#[serde(default)]
|
||||
archived_legacy_issuers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuardState {
|
||||
active_request_id: Option<String>,
|
||||
owners: BTreeMap<String, GuardOwner>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuardOwner {
|
||||
receipt: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
fn reject(message: &'static str) -> RuntimeError {
|
||||
RuntimeError::InvalidRequest(format!("stopped Worker fixture: {message}"))
|
||||
}
|
||||
|
||||
fn io<T>(result: std::io::Result<T>) -> Result<T, RuntimeError> {
|
||||
result.map_err(|_| reject("filesystem operation failed"))
|
||||
}
|
||||
|
||||
fn decode<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, RuntimeError> {
|
||||
serde_json::from_slice(bytes).map_err(|_| reject("invalid typed document"))
|
||||
}
|
||||
|
||||
fn no_symlink_components(path: &Path) -> Result<(), RuntimeError> {
|
||||
let mut current = PathBuf::new();
|
||||
for part in path.components() {
|
||||
if matches!(part, std::path::Component::ParentDir) {
|
||||
return Err(reject("parent traversal is unsupported"));
|
||||
}
|
||||
current.push(part);
|
||||
let metadata = io(fs::symlink_metadata(¤t))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(reject("symlinks are unsupported"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_tree(path: &Path, budget: &mut usize) -> Result<(), RuntimeError> {
|
||||
if *budget == 0 {
|
||||
return Err(reject("file count exceeds fixture limit"));
|
||||
}
|
||||
*budget -= 1;
|
||||
let metadata = io(fs::symlink_metadata(path))?;
|
||||
if metadata.is_dir() {
|
||||
for entry in io(fs::read_dir(path))? {
|
||||
check_tree(&io(entry)?.path(), budget)?;
|
||||
}
|
||||
} else if !metadata.is_file() {
|
||||
return Err(reject("symlinks and special files are unsupported"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn regular_file(path: &Path) -> Result<(), RuntimeError> {
|
||||
no_symlink_components(path)?;
|
||||
if !io(fs::symlink_metadata(path))?.is_file() {
|
||||
return Err(reject("expected a regular file"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bytes(path: &Path) -> Result<Vec<u8>, RuntimeError> {
|
||||
regular_file(path)?;
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOATIME | libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = io(options.open(path))?;
|
||||
let mut result = Vec::new();
|
||||
io(file
|
||||
.take(MAX_SESSION_BYTES as u64 + 1)
|
||||
.read_to_end(&mut result))?;
|
||||
if result.len() > MAX_SESSION_BYTES {
|
||||
return Err(reject("file exceeds fixture limit"));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn text(path: &Path) -> Result<String, RuntimeError> {
|
||||
String::from_utf8(bytes(path)?).map_err(|_| reject("invalid UTF-8 document"))
|
||||
}
|
||||
|
||||
fn portable_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 256
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"-_".contains(&c))
|
||||
}
|
||||
|
||||
impl StoppedWorkerTemplate {
|
||||
pub fn runtime_id(&self) -> &str {
|
||||
&self.runtime_id
|
||||
}
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
&self.workspace_id
|
||||
}
|
||||
pub fn worker_id(&self) -> WorkerId {
|
||||
self.worker_id
|
||||
}
|
||||
pub fn worker_name(&self) -> String {
|
||||
runtime_worker_name(self.worker_id)
|
||||
}
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
}
|
||||
pub fn segment_id(&self) -> SegmentId {
|
||||
self.segment_id
|
||||
}
|
||||
|
||||
/// Extract without migration, rewriting metadata, or copying credentials,
|
||||
/// resolved manifests, execution-operation receipts, or run directories.
|
||||
/// The source Runtime must be offline; its existing owner lock is held for
|
||||
/// the entire read. The path must be `<runtime>/workers/<WorkerId>`.
|
||||
pub fn extract(worker_dir: &Path) -> Result<Self, RuntimeError> {
|
||||
no_symlink_components(worker_dir)?;
|
||||
let workers = worker_dir
|
||||
.parent()
|
||||
.ok_or_else(|| reject("invalid aggregate path"))?;
|
||||
if workers.file_name().and_then(|s| s.to_str()) != Some(WORKERS_DIR) {
|
||||
return Err(reject("invalid aggregate path"));
|
||||
}
|
||||
let root = workers
|
||||
.parent()
|
||||
.ok_or_else(|| reject("invalid Runtime path"))?;
|
||||
// Require the existing lock, rather than creating a file in the source.
|
||||
let lock_path = root.with_file_name(format!(
|
||||
".{}.runtime-owner.lock",
|
||||
root.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| reject("invalid Runtime path"))?
|
||||
));
|
||||
regular_file(&lock_path)?;
|
||||
let lock = io(OpenOptions::new().read(true).open(lock_path))?;
|
||||
if !io(FileExt::try_lock_shared(&lock))? {
|
||||
return Err(reject("source Runtime is not offline"));
|
||||
}
|
||||
let _lock = RuntimeStoreOwnerLock { file: lock };
|
||||
check_tree(worker_dir, &mut 256)?;
|
||||
let snapshot: RuntimeSnapshot = decode(&bytes(&root.join(RUNTIME_FILE))?)?;
|
||||
snapshot
|
||||
.validate(&root.join(RUNTIME_FILE))
|
||||
.map_err(|_| reject("unsupported Runtime schema"))?;
|
||||
if !snapshot.subject_create_recovery_fences.is_empty()
|
||||
|| !snapshot.subject_releases.is_empty()
|
||||
{
|
||||
return Err(reject("source has lifecycle operations"));
|
||||
}
|
||||
let record: WorkerAggregateRecord = decode(&bytes(&worker_dir.join(WORKER_FILE))?)?;
|
||||
let record = record
|
||||
.validate(&worker_dir.join(WORKER_FILE))
|
||||
.map_err(|_| reject("invalid Worker schema or identity"))?;
|
||||
let guard: GuardState = decode(
|
||||
&serde_json::to_vec(&record.restore_guard)
|
||||
.map_err(|_| reject("invalid restore guard"))?,
|
||||
)?;
|
||||
if record.status != WorkerStatus::Stopped
|
||||
|| guard.active_request_id.is_some()
|
||||
|| guard.owners.values().any(|owner| owner.receipt.is_none())
|
||||
{
|
||||
return Err(reject("Worker is not settled and stopped"));
|
||||
}
|
||||
let PersistedWorkerExecutionState::Available(execution) = &record.execution_state else {
|
||||
return Err(reject("pending or unavailable execution metadata"));
|
||||
};
|
||||
// A stopped aggregate may retain the empty historical binding marker.
|
||||
// It is deliberately not copied to the installed Worker.
|
||||
if execution.restore_intent != WorkerRestoreIntent::Explicit {
|
||||
return Err(reject("Worker requires explicit restore"));
|
||||
}
|
||||
if !record.workdir_attachments.is_empty() || !record.logical_workdir_attachments.is_empty()
|
||||
{
|
||||
return Err(reject("attachments are unsupported"));
|
||||
}
|
||||
check_request(&execution.request, false)?;
|
||||
let workspace_id = record
|
||||
.workspace_id
|
||||
.ok_or_else(|| reject("missing Workspace identity"))?;
|
||||
let metadata: WorkerMetadata = decode(&bytes(&worker_dir.join(WORKER_METADATA_FILE))?)?;
|
||||
crate::worker_backend::validate_fixture_restore_metadata(&metadata, &execution.request)
|
||||
.map_err(|_| reject("invalid source restore Manifest or settings"))?;
|
||||
if metadata.worker_name != runtime_worker_name(record.worker_id)
|
||||
|| metadata.workspace_id.as_deref() != Some(&workspace_id)
|
||||
|| !metadata.spawned_children.is_empty()
|
||||
|| !metadata.reclaimed_children.is_empty()
|
||||
|| !metadata.peers.is_empty()
|
||||
|| metadata.subjektiv_session_attribution.is_some()
|
||||
{
|
||||
return Err(reject("unsupported or inconsistent Worker metadata"));
|
||||
}
|
||||
let active = metadata
|
||||
.active
|
||||
.ok_or_else(|| reject("missing active Session"))?;
|
||||
let segment_id = active
|
||||
.segment_id
|
||||
.ok_or_else(|| reject("unmaterialized Session"))?;
|
||||
let source_auth: SourceAuth = toml::from_str(&text(&root.join("auth.toml"))?)
|
||||
.map_err(|_| reject("missing Runtime identity"))?;
|
||||
let session = worker_dir.join("session");
|
||||
let fixture = Self {
|
||||
template_version: TEMPLATE_VERSION,
|
||||
runtime_schema_version: SCHEMA_VERSION,
|
||||
runtime_id: source_auth.identity.identity_id,
|
||||
workspace_id,
|
||||
worker_id: record.worker_id,
|
||||
created_at_ms: record.created_at_ms,
|
||||
session_id: active.session_id,
|
||||
segment_id,
|
||||
session_manifest: bytes(&session.join("session.json"))?,
|
||||
segment_jsonl: bytes(&session.join("segments").join(format!("{segment_id}.jsonl")))?,
|
||||
};
|
||||
fixture.validate_shape()?;
|
||||
fixture.validate_session(&session)?;
|
||||
Ok(fixture)
|
||||
}
|
||||
|
||||
/// Install after normal identity/trust initialization, before Runtime start.
|
||||
/// `request` and `manifest` supply NEW backend/config/profile references and
|
||||
/// freshly resolved policy, not a launch. The saved source Manifest is never
|
||||
/// reused. `manifest.worker.name` must equal `worker_name()`.
|
||||
/// Only `auth.toml` may already exist. The owner lock fences live Runtimes;
|
||||
/// staged files are synced and published without overwriting any Worker.
|
||||
/// An interrupted publication fails closed as an incomplete Runtime layout.
|
||||
pub fn install(
|
||||
&self,
|
||||
options: &FsRuntimeStoreOptions,
|
||||
request: CreateWorkerRequest,
|
||||
manifest: &manifest::WorkerManifest,
|
||||
) -> Result<(), RuntimeError> {
|
||||
self.validate_shape()?;
|
||||
check_request(&request, true)?;
|
||||
if manifest.worker.name != self.worker_name() {
|
||||
return Err(reject("resolved Manifest Worker name mismatch"));
|
||||
}
|
||||
let mut metadata = WorkerMetadata::new(
|
||||
self.worker_name(),
|
||||
Some(WorkerActiveSegmentRef {
|
||||
session_id: self.session_id,
|
||||
segment_id: Some(self.segment_id),
|
||||
}),
|
||||
)
|
||||
.with_workspace_id(&self.workspace_id);
|
||||
metadata.resolved_manifest_snapshot = Some(
|
||||
manifest::write_persisted_worker_manifest_snapshot(manifest)
|
||||
.map_err(|_| reject("invalid resolved Manifest"))?,
|
||||
);
|
||||
crate::worker_backend::validate_fixture_restore_metadata(&metadata, &request)
|
||||
.map_err(|_| reject("resolved Manifest does not match fresh request settings"))?;
|
||||
if options.runtime_id != self.runtime_id
|
||||
|| request.worker_id != self.worker_id
|
||||
|| request
|
||||
.workspace_api
|
||||
.as_ref()
|
||||
.map(|api| api.workspace_id.as_str())
|
||||
!= Some(&self.workspace_id)
|
||||
{
|
||||
return Err(reject("installation identity mismatch"));
|
||||
}
|
||||
no_symlink_components(&options.root)?;
|
||||
check_tree(&options.root, &mut 256)?;
|
||||
// Validate the lock location before the ordinary owner-lock opener.
|
||||
let lock_path = options.root.with_file_name(format!(
|
||||
".{}.runtime-owner.lock",
|
||||
options
|
||||
.root
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| reject("invalid Runtime path"))?
|
||||
));
|
||||
if fs::symlink_metadata(&lock_path).is_ok() {
|
||||
regular_file(&lock_path)?;
|
||||
}
|
||||
let lock = acquire_runtime_store_owner_lock(&options.root)?;
|
||||
validate_fresh_runtime_store_layout(&options.root)
|
||||
.map_err(|_| reject("destination is not fresh; refusing overwrite"))?;
|
||||
let auth: InitializedAuth = toml::from_str(&text(&options.root.join("auth.toml"))?)
|
||||
.map_err(|_| reject("initialize Runtime identity and trust before install"))?;
|
||||
if auth.identity.identity_id != self.runtime_id
|
||||
|| !auth.archived_legacy_issuers.is_empty()
|
||||
|| auth.identity.signing_key().is_err()
|
||||
{
|
||||
return Err(reject("invalid destination Runtime identity"));
|
||||
}
|
||||
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
|
||||
.map_err(|_| reject("invalid destination Workspace trust"))?;
|
||||
let api = request
|
||||
.workspace_api
|
||||
.as_ref()
|
||||
.expect("validated Workspace binding");
|
||||
if !auth.workspace_issuers.iter().any(|trust| {
|
||||
trust.workspace_id == self.workspace_id
|
||||
&& trust.backend_url == api.base_url
|
||||
&& trust.state == WorkspaceIssuerTrustState::Active
|
||||
}) {
|
||||
return Err(reject("request does not match initialized Workspace trust"));
|
||||
}
|
||||
let stage = options
|
||||
.root
|
||||
.join(format!(".fixture-{}", uuid::Uuid::now_v7()));
|
||||
io(fs::create_dir(&stage))?;
|
||||
let store = FsRuntimeStore {
|
||||
root: stage.clone(),
|
||||
_owner_lock: Some(lock),
|
||||
worker_write_fault: Arc::new(AtomicU8::new(AtomicWriteFault::None as u8)),
|
||||
};
|
||||
let result = (|| {
|
||||
let worker = PersistedWorkerRecord {
|
||||
restore_guard: crate::runtime::RestoreGuard::default(),
|
||||
worker_ref: WorkerRef::new(self.worker_id),
|
||||
worker_id: self.worker_id,
|
||||
profile: request.profile.clone(),
|
||||
display_name: request.display_name.clone(),
|
||||
profile_source: request.profile_source.reference(),
|
||||
config_bundle: request.config_bundle.clone(),
|
||||
created_at_ms: self.created_at_ms,
|
||||
status: WorkerStatus::Stopped,
|
||||
last_finished_submission_request_id: None,
|
||||
workspace_id: Some(self.workspace_id.clone()),
|
||||
workdir_attachments: Vec::new(),
|
||||
logical_workdir_attachments: Vec::new(),
|
||||
execution_state: PersistedWorkerExecutionState::Available(
|
||||
PersistedWorkerExecution {
|
||||
request,
|
||||
binding: None,
|
||||
restore_intent: WorkerRestoreIntent::Explicit,
|
||||
},
|
||||
),
|
||||
};
|
||||
WorkerAggregateRecord::from_persisted(&worker)
|
||||
.validate(&store.worker_dir(&self.worker_id).join(WORKER_FILE))
|
||||
.map_err(|_| reject("invalid installation request"))?;
|
||||
store.write_worker_record(&worker)?;
|
||||
let worker_dir = store.worker_dir(&self.worker_id);
|
||||
atomic_write_json(
|
||||
&worker_dir.join(WORKER_METADATA_FILE),
|
||||
&metadata,
|
||||
"write fixture metadata",
|
||||
)?;
|
||||
let session = worker_dir.join("session");
|
||||
io(fs::create_dir_all(session.join("segments")))?;
|
||||
write_new(&session.join("session.json"), &self.session_manifest)?;
|
||||
write_new(
|
||||
&session
|
||||
.join("segments")
|
||||
.join(format!("{}.jsonl", self.segment_id)),
|
||||
&self.segment_jsonl,
|
||||
)?;
|
||||
self.validate_session(&session)?;
|
||||
store.write_runtime_snapshot(&PersistedRuntimeState {
|
||||
subject_create_recovery_fences: BTreeMap::new(),
|
||||
subject_releases: Vec::new(),
|
||||
// Match normal store initialization: this is catalog availability,
|
||||
// not a process or Worker execution start.
|
||||
display_name: options.display_name.clone(),
|
||||
status: RuntimeStatus::Running,
|
||||
next_diagnostic_id: 1,
|
||||
workers: BTreeMap::new(),
|
||||
workspace_owners: BTreeMap::new(),
|
||||
diagnostics: Vec::new(),
|
||||
})?;
|
||||
let loaded = store.load_runtime_state()?;
|
||||
if loaded.workers.len() != 1 || !loaded.diagnostics.is_empty() {
|
||||
return Err(reject("staged aggregate failed ordinary store roundtrip"));
|
||||
}
|
||||
sync_runtime_tree(&stage)?;
|
||||
// No destination existed when checked under the owner lock. Hard-link
|
||||
// the snapshot last with create-new semantics; never replace a file.
|
||||
// Reserve the destination catalog with create-new semantics. Even
|
||||
// an empty preexisting workers directory must never be replaced.
|
||||
let destination_workers = options.root.join(WORKERS_DIR);
|
||||
io(fs::create_dir(&destination_workers))?;
|
||||
io(fs::rename(
|
||||
store.worker_dir(&self.worker_id),
|
||||
destination_workers.join(self.worker_id.to_string()),
|
||||
))?;
|
||||
sync_directory(&destination_workers, "publish stopped Worker aggregate")?;
|
||||
io(fs::hard_link(
|
||||
stage.join(RUNTIME_FILE),
|
||||
options.root.join(RUNTIME_FILE),
|
||||
))?;
|
||||
sync_directory(&options.root, "publish stopped fixture")?;
|
||||
Ok(())
|
||||
})();
|
||||
// Only remove our private staging directory, never existing destination data.
|
||||
let cleanup = fs::remove_dir_all(&stage);
|
||||
result?;
|
||||
io(cleanup)?;
|
||||
sync_directory(&options.root, "finish stopped fixture")
|
||||
}
|
||||
|
||||
fn validate_shape(&self) -> Result<(), RuntimeError> {
|
||||
if self.template_version != TEMPLATE_VERSION
|
||||
|| self.runtime_schema_version != SCHEMA_VERSION
|
||||
{
|
||||
return Err(reject("unsupported template schema"));
|
||||
}
|
||||
if !portable_id(&self.runtime_id)
|
||||
|| !portable_id(&self.workspace_id)
|
||||
|| self.session_id.get_version_num() != 7
|
||||
|| self.segment_id.get_version_num() != 7
|
||||
|| self.session_manifest.len() > MAX_SESSION_BYTES
|
||||
|| self.segment_jsonl.len() > MAX_SESSION_BYTES
|
||||
{
|
||||
return Err(reject("invalid portable identity or size"));
|
||||
}
|
||||
let manifest: SessionManifest = decode(&self.session_manifest)?;
|
||||
if manifest.session_id != self.session_id
|
||||
|| manifest.schema_version == 0
|
||||
|| !self.segment_jsonl.ends_with(b"\n")
|
||||
{
|
||||
return Err(reject("Session identity or committed boundary mismatch"));
|
||||
}
|
||||
// Read-only JSON traversal, never mutation/reconstruction of Session bytes.
|
||||
// Reject known secret/host markers even in otherwise valid typed records.
|
||||
for line in self
|
||||
.segment_jsonl
|
||||
.split(|b| *b == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
let _: PortableJson = decode(line)?;
|
||||
validate_preserved_record(line)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_history_entry(
|
||||
&self,
|
||||
entry: &session_store::LoggedHistoryEntry,
|
||||
ids: &mut std::collections::BTreeSet<String>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
if !portable_id(&entry.metadata.entry_id.0)
|
||||
|| !ids.insert(entry.metadata.entry_id.0.clone())
|
||||
{
|
||||
return Err(reject("duplicate or invalid entry identity"));
|
||||
}
|
||||
if entry
|
||||
.metadata
|
||||
.input_metadata
|
||||
.as_ref()
|
||||
.is_some_and(|metadata| {
|
||||
metadata.feature_payload().is_some() || metadata.public_item().is_some()
|
||||
})
|
||||
{
|
||||
// These are opaque owner-specific payloads, not part of the narrow
|
||||
// artificial conversation contract. Never preserve unchecked JSON.
|
||||
return Err(reject("Feature history payloads are unsupported"));
|
||||
}
|
||||
if !matches!(
|
||||
entry.item,
|
||||
session_store::LoggedItem::Message { .. }
|
||||
| session_store::LoggedItem::AssistantResponseBoundary { .. }
|
||||
) {
|
||||
return Err(reject("only text conversation records are supported"));
|
||||
}
|
||||
if let session_store::LoggedSessionHistoryOrigin::ModelOutput { worker } =
|
||||
&entry.metadata.origin
|
||||
{
|
||||
// Session producer IDs identify the original execution, not the
|
||||
// Workspace catalog Worker. Preserve them; do not rebind/rewrite them.
|
||||
if !portable_id(&worker.worker_id)
|
||||
|| worker
|
||||
.workspace_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| id != &self.workspace_id)
|
||||
|| worker
|
||||
.runtime_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| id != &self.runtime_id)
|
||||
{
|
||||
return Err(reject("Session origin identity mismatch"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_session(&self, root: &Path) -> Result<(), RuntimeError> {
|
||||
for entry in io(fs::read_dir(root))? {
|
||||
let entry = io(entry)?;
|
||||
match entry.file_name().to_str() {
|
||||
Some("session.json" | "segments") => {}
|
||||
Some("artifacts") => check_empty_artifacts(&entry.path())?,
|
||||
_ => return Err(reject("unsupported Session files")),
|
||||
}
|
||||
}
|
||||
let entries = io(fs::read_dir(root.join("segments")))?.collect::<Result<Vec<_>, _>>();
|
||||
let entries = io(entries)?;
|
||||
if entries.len() != 1
|
||||
|| entries[0].file_name() != format!("{}.jsonl", self.segment_id).as_str()
|
||||
{
|
||||
return Err(reject("only the fixed single-Segment fixture is supported"));
|
||||
}
|
||||
let store = WorkerSessionStore::open_read_only(root)
|
||||
.map_err(|_| reject("unsupported Session schema"))?;
|
||||
let logs = store
|
||||
.read_all_read_only_bounded(self.session_id, self.segment_id, MAX_SESSION_BYTES as u64)
|
||||
.map_err(|_| reject("invalid canonical Session records"))?
|
||||
.0;
|
||||
if !matches!(logs.first(), Some(session_store::LogEntry::AnnotatedSegmentStart {
|
||||
session_id, system_prompt: Some(_), forked_from: None, compacted_from: None, ..
|
||||
}) if *session_id == self.session_id)
|
||||
{
|
||||
return Err(reject(
|
||||
"missing system prompt, invalid Segment seed, or unsupported lineage",
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for (index, log) in logs.iter().enumerate() {
|
||||
use session_store::LogEntry;
|
||||
match log {
|
||||
LogEntry::AnnotatedSegmentStart { history, .. }
|
||||
if index == 0 && history.is_empty() => {}
|
||||
LogEntry::AnnotatedUserInput {
|
||||
history,
|
||||
extensions,
|
||||
..
|
||||
} => {
|
||||
for extension in extensions {
|
||||
validate_settled_activation(extension)?;
|
||||
}
|
||||
for entry in history {
|
||||
self.validate_history_entry(entry, &mut ids)?;
|
||||
}
|
||||
}
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. } => {
|
||||
self.validate_history_entry(entry, &mut ids)?
|
||||
}
|
||||
LogEntry::RunCompleted {
|
||||
entry_id: Some(id), ..
|
||||
} => {
|
||||
if !portable_id(&id.0) || !ids.insert(id.0.clone()) {
|
||||
return Err(reject("duplicate or invalid entry identity"));
|
||||
}
|
||||
}
|
||||
LogEntry::Invoke { .. } | LogEntry::LlmUsage { .. } | LogEntry::TurnEnd { .. } => {}
|
||||
_ => {
|
||||
return Err(reject(
|
||||
"unsupported conversation record or pending operation",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let state = session_store::collect_state(&logs);
|
||||
if state.last_run_interrupted || state.active_run_turn_count.is_some() {
|
||||
return Err(reject("Session has pending execution"));
|
||||
}
|
||||
for segments in &state.user_segments {
|
||||
if segments
|
||||
.iter()
|
||||
.any(|segment| !matches!(segment, protocol::Segment::Text { .. }))
|
||||
{
|
||||
return Err(reject("Session attachments are unsupported"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Compare *fields*, not re-encoded bytes or scalar values (e.g. f32 JSON
|
||||
// normalization). Serde's permissive/untagged/flattened compatibility readers
|
||||
// can drop unknown data without surfacing it to an ignored-field callback. A
|
||||
// retained field must survive the owner's typed projection at every depth.
|
||||
// This intentionally accepts only canonical writer shapes: even an explicit
|
||||
// empty optional field omitted by that writer is rejected, not repaired.
|
||||
// The projection is validation-only; installation always writes original bytes.
|
||||
fn validate_preserved_record(bytes: &[u8]) -> Result<(), RuntimeError> {
|
||||
let original: serde_json::Value = decode(bytes)?;
|
||||
let record: session_store::LogEntry = decode(bytes)?;
|
||||
let projection =
|
||||
serde_json::to_value(&record).map_err(|_| reject("invalid typed Session record"))?;
|
||||
require_preserved_fields(&original, &projection)
|
||||
}
|
||||
|
||||
fn require_preserved_fields(
|
||||
original: &serde_json::Value,
|
||||
projection: &serde_json::Value,
|
||||
) -> Result<(), RuntimeError> {
|
||||
use serde_json::Value;
|
||||
match (original, projection) {
|
||||
(Value::Object(original), Value::Object(projection)) => {
|
||||
for (key, value) in original {
|
||||
let projected = projection
|
||||
.get(key)
|
||||
.ok_or_else(|| reject("unknown or noncanonical Session field"))?;
|
||||
require_preserved_fields(value, projected)?;
|
||||
}
|
||||
}
|
||||
(Value::Array(original), Value::Array(projection))
|
||||
if original.len() == projection.len() =>
|
||||
{
|
||||
for (value, projected) in original.iter().zip(projection) {
|
||||
require_preserved_fields(value, projected)?;
|
||||
}
|
||||
}
|
||||
(Value::Null, Value::Null)
|
||||
| (Value::Bool(_), Value::Bool(_))
|
||||
| (Value::Number(_), Value::Number(_))
|
||||
| (Value::String(_), Value::String(_)) => {}
|
||||
_ => return Err(reject("noncanonical Session field shape")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Exact receipt wire fields owned by worker::PendingActivationState. Keep these
|
||||
// fixture-only mirrors strict; do not change the production compatibility reader.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubmissionReceipt {
|
||||
submission_request_id: String,
|
||||
source_namespace: String,
|
||||
submission_id: String,
|
||||
payload_digest: String,
|
||||
disposition: protocol::SubmissionDisposition,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct NotificationReceipt {
|
||||
notification_request_id: String,
|
||||
source_namespace: String,
|
||||
payload_digest: String,
|
||||
}
|
||||
|
||||
// The approved conversation has committed submission checkpoints in its original
|
||||
// Session records. Keep their receipt bytes, but reject any resumable queue or
|
||||
// Feature invocation history. This projection confers no execution authority.
|
||||
fn validate_settled_activation(
|
||||
extension: &session_store::SessionExtension,
|
||||
) -> Result<(), RuntimeError> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Checkpoint {
|
||||
#[serde(rename = "next_activation_sequence")]
|
||||
_next_activation_sequence: u64,
|
||||
activating: Option<serde::de::IgnoredAny>,
|
||||
activating_notification: Vec<serde::de::IgnoredAny>,
|
||||
pending: Vec<serde::de::IgnoredAny>,
|
||||
pending_notifications: Vec<serde::de::IgnoredAny>,
|
||||
#[serde(rename = "receipts")]
|
||||
_receipts: Vec<SubmissionReceipt>,
|
||||
#[serde(rename = "notification_receipts")]
|
||||
_notification_receipts: Vec<NotificationReceipt>,
|
||||
invocation_receipts: Vec<serde::de::IgnoredAny>,
|
||||
invocation_recovery_blocked: bool,
|
||||
}
|
||||
if extension.domain != "worker.pending_activations.v1" {
|
||||
return Err(reject("unsupported Session extension"));
|
||||
}
|
||||
let checkpoint: Checkpoint = serde_json::from_value(extension.payload.clone())
|
||||
.map_err(|_| reject("invalid Session activation checkpoint"))?;
|
||||
if checkpoint.activating.is_some()
|
||||
|| !checkpoint.activating_notification.is_empty()
|
||||
|| !checkpoint.pending.is_empty()
|
||||
|| !checkpoint.pending_notifications.is_empty()
|
||||
|| !checkpoint.invocation_receipts.is_empty()
|
||||
|| checkpoint.invocation_recovery_blocked
|
||||
{
|
||||
return Err(reject(
|
||||
"Session has pending activation or invocation history",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_request(request: &CreateWorkerRequest, install: bool) -> Result<(), RuntimeError> {
|
||||
if !request.workdir_attachment_requests.is_empty()
|
||||
|| !request.workdir_attachments.is_empty()
|
||||
|| request.backend_job.is_some()
|
||||
|| request.subjektiv_attached
|
||||
|| request.worker_observation_enabled
|
||||
|| !request.worker_observation_grants.is_empty()
|
||||
|| (install && request.initial_input.is_some())
|
||||
{
|
||||
return Err(reject(
|
||||
"unsupported capabilities, attachments, or initial input",
|
||||
));
|
||||
}
|
||||
if install
|
||||
&& (request.create_fingerprint.trim().is_empty()
|
||||
|| request.config_bundle.is_none()
|
||||
|| !matches!(
|
||||
request.profile_source,
|
||||
ProfileSourceArchiveSource::WorkspaceConfig { .. }
|
||||
))
|
||||
{
|
||||
return Err(reject(
|
||||
"fresh config/profile references and create fingerprint are required",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_empty_artifacts(root: &Path) -> Result<(), RuntimeError> {
|
||||
for entry in io(fs::read_dir(root))? {
|
||||
let entry = io(entry)?;
|
||||
let metadata = io(entry.metadata())?;
|
||||
if metadata.is_dir() {
|
||||
check_empty_artifacts(&entry.path())?;
|
||||
} else if entry.file_name() != ".aggregate.lock" || metadata.len() != 0 {
|
||||
return Err(reject("Session artifacts are unsupported"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<(), RuntimeError> {
|
||||
let mut file = io(OpenOptions::new().write(true).create_new(true).open(path))?;
|
||||
io(file.write_all(bytes))?;
|
||||
io(file.sync_all())
|
||||
}
|
||||
|
||||
// A streaming validation visitor, not a generic document available to callers.
|
||||
struct PortableJson;
|
||||
impl<'de> Deserialize<'de> for PortableJson {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = PortableJson;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("portable fixture JSON")
|
||||
}
|
||||
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<PortableJson, E> {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
if [
|
||||
"://",
|
||||
"private key",
|
||||
"bearer ",
|
||||
"sk-",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"private_key",
|
||||
"authorization",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
|| value
|
||||
.split_whitespace()
|
||||
.any(|word| word.starts_with('/') || word.contains(":\\"))
|
||||
{
|
||||
return Err(E::custom("nonportable or sensitive fixture material"));
|
||||
}
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(
|
||||
self,
|
||||
mut seq: A,
|
||||
) -> Result<PortableJson, A::Error> {
|
||||
while seq.next_element::<PortableJson>()?.is_some() {}
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: A,
|
||||
) -> Result<PortableJson, A::Error> {
|
||||
let mut keys = std::collections::BTreeSet::new();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
// A duplicate key can hide discarded data even inside an
|
||||
// otherwise opaque extension payload. Never retain it.
|
||||
if !keys.insert(key.clone()) {
|
||||
return Err(serde::de::Error::custom("duplicate fixture JSON field"));
|
||||
}
|
||||
serde::de::Visitor::visit_str::<A::Error>(Visitor, &key)?;
|
||||
map.next_value::<PortableJson>()?;
|
||||
}
|
||||
Ok(PortableJson)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
use super::*;
|
||||
use crate::auth::RuntimeIdentityMaterial;
|
||||
use crate::catalog::{ProfileSelector, WorkspaceApiRef};
|
||||
use crate::profile_archive::ProfileSourceGraphSummary;
|
||||
use session_store::{LogEntry, Store};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const RUNTIME: &str = "fixture-runtime";
|
||||
const WORKSPACE: &str = "fixture-workspace";
|
||||
const NEW_URL: &str = "http://127.0.0.1:34567";
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum ReceiptFixture<T> {
|
||||
Valid(T),
|
||||
Malformed(u8),
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
struct CheckpointFixture {
|
||||
next_activation_sequence: u64,
|
||||
activating: Option<u8>,
|
||||
activating_notification: Vec<u8>,
|
||||
pending: Vec<u8>,
|
||||
pending_notifications: Vec<u8>,
|
||||
receipts: Vec<ReceiptFixture<SubmissionReceipt>>,
|
||||
notification_receipts: Vec<ReceiptFixture<NotificationReceipt>>,
|
||||
invocation_receipts: Vec<u8>,
|
||||
invocation_recovery_blocked: bool,
|
||||
}
|
||||
|
||||
fn settled_checkpoint() -> CheckpointFixture {
|
||||
CheckpointFixture {
|
||||
next_activation_sequence: 2,
|
||||
receipts: vec![ReceiptFixture::Valid(SubmissionReceipt {
|
||||
submission_request_id: "completed-submission-request".into(),
|
||||
source_namespace: "fixture-source".into(),
|
||||
submission_id: "completed-submission".into(),
|
||||
payload_digest: "fixture-payload-digest".into(),
|
||||
disposition: protocol::SubmissionDisposition::Started,
|
||||
})],
|
||||
notification_receipts: vec![ReceiptFixture::Valid(NotificationReceipt {
|
||||
notification_request_id: "completed-notification-request".into(),
|
||||
source_namespace: "fixture-source".into(),
|
||||
payload_digest: "fixture-notification-digest".into(),
|
||||
})],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint_extension(checkpoint: &CheckpointFixture) -> session_store::SessionExtension {
|
||||
session_store::SessionExtension::new(
|
||||
"worker.pending_activations.v1",
|
||||
serde_json::to_value(checkpoint).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn worker_id() -> WorkerId {
|
||||
"01a1657d-b762-7791-87ae-a49f28cadbce".parse().unwrap()
|
||||
}
|
||||
|
||||
fn resolved() -> manifest::WorkerManifest {
|
||||
let mut config = manifest::WorkerManifestConfig::resolution_defaults();
|
||||
config.worker.name = Some(runtime_worker_name(worker_id()));
|
||||
manifest::WorkerManifest::try_from(config).unwrap()
|
||||
}
|
||||
|
||||
fn request() -> CreateWorkerRequest {
|
||||
CreateWorkerRequest {
|
||||
worker_id: worker_id(),
|
||||
create_fingerprint: "new-create-fingerprint".into(),
|
||||
profile: ProfileSelector::Builtin("default".into()),
|
||||
display_name: Some("fixture".into()),
|
||||
profile_source: ProfileSourceArchiveSource::WorkspaceConfig {
|
||||
archive: ProfileSourceArchiveRef {
|
||||
id: "new-profile".into(),
|
||||
digest: "new-profile-digest".into(),
|
||||
size_bytes: 1,
|
||||
source_graph: ProfileSourceGraphSummary {
|
||||
source_count: 1,
|
||||
total_source_bytes: 1,
|
||||
entrypoints: BTreeMap::from([("builtin:default".into(), "default.dcl".into())]),
|
||||
import_count: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
config_bundle: Some(ConfigBundleRef {
|
||||
id: "new-config".into(),
|
||||
digest: "new-config-digest".into(),
|
||||
}),
|
||||
workspace_api: Some(WorkspaceApiRef {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
base_url: NEW_URL.into(),
|
||||
}),
|
||||
initial_input: None,
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
workdir_attachments: Vec::new(),
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: Vec::new(),
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
language: "en".into(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
backend_job: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize(root: &Path, runtime_id: &str, workspace_id: &str, url: &str) {
|
||||
#[derive(Serialize)]
|
||||
struct Auth {
|
||||
identity: RuntimeIdentityMaterial,
|
||||
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
|
||||
}
|
||||
let issuer = RuntimeIdentityMaterial::generate("fixture-issuer").unwrap();
|
||||
let digest = Sha256::digest(crate::auth::decode_public_key(&issuer.public_key).unwrap());
|
||||
let fingerprint = format!(
|
||||
"sha256:{}",
|
||||
digest
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
let trust = WorkspaceIssuerTrustRecord::from_bundle(
|
||||
server_api::WorkspacePublicIdentityBundle {
|
||||
workspace_id: workspace_id.into(),
|
||||
backend_url: url.into(),
|
||||
key_id: "fixture-issuer".into(),
|
||||
algorithm: "ed25519".into(),
|
||||
public_key: issuer.public_key,
|
||||
public_key_fingerprint: fingerprint,
|
||||
},
|
||||
"fixture-trust".into(),
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(root).unwrap();
|
||||
fs::write(
|
||||
root.join("auth.toml"),
|
||||
toml::to_string(&Auth {
|
||||
identity: RuntimeIdentityMaterial::generate(runtime_id).unwrap(),
|
||||
workspace_issuers: vec![trust],
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn record(request: CreateWorkerRequest) -> PersistedWorkerRecord {
|
||||
PersistedWorkerRecord {
|
||||
restore_guard: crate::runtime::RestoreGuard::default(),
|
||||
worker_ref: WorkerRef::new(request.worker_id),
|
||||
worker_id: request.worker_id,
|
||||
profile: request.profile.clone(),
|
||||
display_name: request.display_name.clone(),
|
||||
profile_source: request.profile_source.reference(),
|
||||
config_bundle: request.config_bundle.clone(),
|
||||
created_at_ms: Some(123456),
|
||||
status: WorkerStatus::Stopped,
|
||||
last_finished_submission_request_id: Some("old-submission-operation".into()),
|
||||
workspace_id: Some(WORKSPACE.into()),
|
||||
workdir_attachments: vec![],
|
||||
logical_workdir_attachments: vec![],
|
||||
execution_state: PersistedWorkerExecutionState::Available(PersistedWorkerExecution {
|
||||
request,
|
||||
binding: Some(PersistedWorkerExecutionBinding {}),
|
||||
restore_intent: WorkerRestoreIntent::Explicit,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn source() -> (tempfile::TempDir, PathBuf, StoppedWorkerTemplate) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime");
|
||||
initialize(&root, RUNTIME, WORKSPACE, "http://127.0.0.1:23456");
|
||||
drop(
|
||||
crate::Runtime::with_fs_store(FsRuntimeStoreOptions::new(&root).with_runtime_id(RUNTIME))
|
||||
.unwrap(),
|
||||
);
|
||||
let opened = FsRuntimeStore::open_or_create(root.clone(), RUNTIME).unwrap();
|
||||
let mut old = request();
|
||||
old.workspace_api.as_mut().unwrap().base_url = "http://127.0.0.1:23456".into();
|
||||
old.config_bundle.as_mut().unwrap().id = "old-config".into();
|
||||
opened.store.write_worker_record(&record(old)).unwrap();
|
||||
let worker = opened.store.worker_dir(&worker_id());
|
||||
let session_id = "01a1657d-b800-7777-8888-111111111111".parse().unwrap();
|
||||
let segment_id = "01a1657d-b818-77c1-bd73-176172a4bd82".parse().unwrap();
|
||||
let mut metadata = WorkerMetadata::new(
|
||||
runtime_worker_name(worker_id()),
|
||||
Some(WorkerActiveSegmentRef {
|
||||
session_id,
|
||||
segment_id: Some(segment_id),
|
||||
}),
|
||||
)
|
||||
.with_workspace_id(WORKSPACE)
|
||||
.with_workspace_root(PathBuf::from("/old/host/path"));
|
||||
metadata.resolved_manifest_snapshot =
|
||||
Some(manifest::write_persisted_worker_manifest_snapshot(&resolved()).unwrap());
|
||||
atomic_write_json(
|
||||
&worker.join(WORKER_METADATA_FILE),
|
||||
&metadata,
|
||||
"test metadata",
|
||||
)
|
||||
.unwrap();
|
||||
let sessions = WorkerSessionStore::new(worker.join("session")).unwrap();
|
||||
let history = session_store::LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::Message {
|
||||
role: session_store::LoggedRole::Assistant,
|
||||
content: vec![session_store::LoggedContentPart::Text {
|
||||
text: "artificial fixture reply".into(),
|
||||
}],
|
||||
},
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId("fixed-entry-id".into()),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::ModelOutput {
|
||||
worker: session_store::LoggedWorkerSubject {
|
||||
workspace_id: Some(WORKSPACE.into()),
|
||||
runtime_id: Some(RUNTIME.into()),
|
||||
worker_id: worker_id().to_string(),
|
||||
},
|
||||
},
|
||||
derivation: None,
|
||||
input_metadata: None,
|
||||
},
|
||||
};
|
||||
sessions
|
||||
.create_segment(
|
||||
session_id,
|
||||
segment_id,
|
||||
&[
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 123,
|
||||
session_id,
|
||||
system_prompt: Some("Harmless artificial fixture instructions.".into()),
|
||||
config: Default::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::Invoke {
|
||||
ts: 124,
|
||||
trigger: protocol::InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 124,
|
||||
segments: vec![protocol::Segment::text(
|
||||
"Harmless artificial fixture input.",
|
||||
)],
|
||||
extensions: vec![checkpoint_extension(&settled_checkpoint())],
|
||||
history: vec![session_store::LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::Message {
|
||||
role: session_store::LoggedRole::User,
|
||||
content: vec![session_store::LoggedContentPart::Text {
|
||||
text: "Harmless artificial fixture input.".into(),
|
||||
}],
|
||||
},
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId(
|
||||
"fixed-user-id".into(),
|
||||
),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::BackendInstruction {
|
||||
operation_id: None,
|
||||
},
|
||||
derivation: None,
|
||||
input_metadata: None,
|
||||
},
|
||||
}],
|
||||
},
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 125,
|
||||
entry: history,
|
||||
},
|
||||
LogEntry::LlmUsage {
|
||||
ts: 125,
|
||||
history_len: 2,
|
||||
input_total_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 126,
|
||||
turn_count: 1,
|
||||
},
|
||||
LogEntry::RunCompleted {
|
||||
entry_id: Some(session_store::LoggedSessionHistoryEntryId(
|
||||
"fixed-terminal-id".into(),
|
||||
)),
|
||||
ts: 126,
|
||||
interrupted: false,
|
||||
result: agen::EngineResult::Finished,
|
||||
active_run_turn_count: None,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(worker.join("runs/old-run")).unwrap();
|
||||
fs::write(worker.join("runs/old-run/private.log"), "not exported").unwrap();
|
||||
drop(opened);
|
||||
let template = StoppedWorkerTemplate::extract(&worker).unwrap();
|
||||
(temp, worker, template)
|
||||
}
|
||||
|
||||
fn destination(template: &StoppedWorkerTemplate) -> (tempfile::TempDir, FsRuntimeStoreOptions) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime");
|
||||
initialize(
|
||||
&root,
|
||||
template.runtime_id(),
|
||||
template.workspace_id(),
|
||||
NEW_URL,
|
||||
);
|
||||
(
|
||||
temp,
|
||||
FsRuntimeStoreOptions::new(root).with_runtime_id(template.runtime_id()),
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_fresh(options: &FsRuntimeStoreOptions) {
|
||||
assert_eq!(fs::read_dir(&options.root).unwrap().count(), 1);
|
||||
assert!(options.root.join("auth.toml").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extraction_install_roundtrip_preserves_bytes_and_is_inactive() {
|
||||
let (_source, worker, template) = source();
|
||||
let original_worker = fs::read(worker.join(WORKER_FILE)).unwrap();
|
||||
let original_metadata = fs::read(worker.join(WORKER_METADATA_FILE)).unwrap();
|
||||
let exported = serde_json::to_vec(&template).unwrap();
|
||||
let exported_text = String::from_utf8(exported.clone()).unwrap();
|
||||
for forbidden in [
|
||||
"old-config",
|
||||
"old-submission-operation",
|
||||
"restore_guard",
|
||||
"private_key",
|
||||
"http://",
|
||||
"/old/host",
|
||||
"private.log",
|
||||
"resolved_manifest_snapshot",
|
||||
] {
|
||||
assert!(!exported_text.contains(forbidden));
|
||||
}
|
||||
let template: StoppedWorkerTemplate = serde_json::from_slice(&exported).unwrap();
|
||||
let (_target, options) = destination(&template);
|
||||
let auth_before = fs::read(options.root.join("auth.toml")).unwrap();
|
||||
template.install(&options, request(), &resolved()).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(options.root.join("auth.toml")).unwrap(),
|
||||
auth_before
|
||||
);
|
||||
assert_eq!(fs::read(worker.join(WORKER_FILE)).unwrap(), original_worker);
|
||||
assert_eq!(
|
||||
fs::read(worker.join(WORKER_METADATA_FILE)).unwrap(),
|
||||
original_metadata
|
||||
);
|
||||
let installed = options
|
||||
.root
|
||||
.join(WORKERS_DIR)
|
||||
.join(template.worker_id().to_string());
|
||||
assert_eq!(
|
||||
fs::read(installed.join("session/session.json")).unwrap(),
|
||||
template.session_manifest
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(installed.join(format!("session/segments/{}.jsonl", template.segment_id())))
|
||||
.unwrap(),
|
||||
template.segment_jsonl
|
||||
);
|
||||
assert!(!installed.join("runs").exists());
|
||||
let metadata: WorkerMetadata =
|
||||
decode(&fs::read(installed.join(WORKER_METADATA_FILE)).unwrap()).unwrap();
|
||||
assert_eq!(metadata.active.unwrap().session_id, template.session_id());
|
||||
assert!(metadata.workspace_root.is_none() && metadata.resolved_manifest_snapshot.is_some());
|
||||
|
||||
let opened = FsRuntimeStore::open_or_create(options.root.clone(), RUNTIME).unwrap();
|
||||
let state = opened.state.unwrap();
|
||||
assert!(state.diagnostics.is_empty());
|
||||
let saved = state.workers.get(&worker_id()).unwrap();
|
||||
assert_eq!(saved.status, WorkerStatus::Stopped);
|
||||
assert!(saved.last_finished_submission_request_id.is_none());
|
||||
let PersistedWorkerExecutionState::Available(execution) = &saved.execution_state else {
|
||||
panic!("not available")
|
||||
};
|
||||
assert_eq!(execution.restore_intent, WorkerRestoreIntent::Explicit);
|
||||
assert!(execution.binding.is_none());
|
||||
assert_eq!(execution.request, request());
|
||||
// Fresh restore guard, no operation owner/receipt history.
|
||||
assert_ne!(
|
||||
serde_json::to_vec(&saved.restore_guard).unwrap(),
|
||||
serde_json::to_vec(
|
||||
&decode::<WorkerAggregateRecord>(&original_worker)
|
||||
.unwrap()
|
||||
.restore_guard
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let guard: GuardState = decode(&serde_json::to_vec(&saved.restore_guard).unwrap()).unwrap();
|
||||
assert!(guard.active_request_id.is_none() && guard.owners.is_empty());
|
||||
drop(opened.store);
|
||||
|
||||
struct NoExecution;
|
||||
impl crate::execution::WorkerExecutionBackend for NoExecution {
|
||||
fn backend_id(&self) -> &str {
|
||||
"fixture-no-execution"
|
||||
}
|
||||
fn spawn_worker(
|
||||
&self,
|
||||
_: crate::execution::WorkerExecutionSpawnRequest,
|
||||
) -> crate::execution::WorkerExecutionSpawnResult {
|
||||
panic!("fixture executed")
|
||||
}
|
||||
fn dispatch_input(
|
||||
&self,
|
||||
_: &WorkerRef,
|
||||
_: crate::interaction::WorkerInput,
|
||||
) -> crate::execution::WorkerExecutionResult {
|
||||
panic!("fixture submitted")
|
||||
}
|
||||
fn restore_worker(
|
||||
&self,
|
||||
_: crate::execution::WorkerExecutionRestoreRequest,
|
||||
) -> crate::execution::WorkerExecutionSpawnResult {
|
||||
panic!("fixture auto-restored")
|
||||
}
|
||||
}
|
||||
let runtime =
|
||||
crate::Runtime::with_fs_store_and_execution_backend(options.clone(), Arc::new(NoExecution))
|
||||
.unwrap();
|
||||
assert_eq!(runtime.list_workers().unwrap().len(), 1);
|
||||
assert_eq!(runtime.summary().unwrap().active_worker_count, 0);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.worker_detail(&WorkerRef::new(worker_id()))
|
||||
.unwrap()
|
||||
.status,
|
||||
WorkerStatus::Stopped
|
||||
);
|
||||
drop(runtime);
|
||||
let runtime = crate::Runtime::with_fs_store(options).unwrap();
|
||||
assert_eq!(
|
||||
runtime.list_workers().unwrap()[0].status,
|
||||
WorkerStatus::Stopped
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_schema_identity_input_and_overwrite() {
|
||||
let (_source, _, template) = source();
|
||||
for change in [
|
||||
"template",
|
||||
"runtime-schema",
|
||||
"session-schema",
|
||||
"session-id",
|
||||
"segment-id",
|
||||
"runtime-id",
|
||||
"workspace-id",
|
||||
"worker-id",
|
||||
"origin-id",
|
||||
"duplicate-id",
|
||||
"truncated",
|
||||
] {
|
||||
let (_target, options) = destination(&template);
|
||||
let mut invalid = template.clone();
|
||||
match change {
|
||||
"template" => invalid.template_version += 1,
|
||||
"runtime-schema" => invalid.runtime_schema_version += 1,
|
||||
"session-schema" => {
|
||||
invalid.session_manifest = format!(
|
||||
"{{\"schema_version\":999,\"session_id\":\"{}\"}}",
|
||||
invalid.session_id
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
"session-id" => invalid.session_id = uuid::Uuid::now_v7(),
|
||||
"segment-id" => invalid.segment_id = uuid::Uuid::nil(), // no external attestation: valid new Segment ID is otherwise portable
|
||||
"runtime-id" => invalid.runtime_id = "other-runtime".into(),
|
||||
"workspace-id" => invalid.workspace_id = "other-workspace".into(),
|
||||
"worker-id" => invalid.worker_id = WorkerId::now_v7(),
|
||||
"origin-id" => {
|
||||
invalid.segment_jsonl = String::from_utf8(invalid.segment_jsonl)
|
||||
.unwrap()
|
||||
.replace(RUNTIME, "other-runtime")
|
||||
.into_bytes()
|
||||
}
|
||||
"duplicate-id" => {
|
||||
let last = invalid
|
||||
.segment_jsonl
|
||||
.split_inclusive(|b| *b == b'\n')
|
||||
.last()
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
invalid.segment_jsonl.extend(last);
|
||||
}
|
||||
"truncated" => {
|
||||
invalid.segment_jsonl.pop();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(
|
||||
invalid.install(&options, request(), &resolved()).is_err(),
|
||||
"accepted {change}"
|
||||
);
|
||||
assert_fresh(&options);
|
||||
}
|
||||
let (_target, options) = destination(&template);
|
||||
let mut launch = request();
|
||||
launch.initial_input = Some(crate::interaction::WorkerInput::user("do not execute"));
|
||||
assert!(template.install(&options, launch, &resolved()).is_err());
|
||||
assert_fresh(&options);
|
||||
template.install(&options, request(), &resolved()).unwrap();
|
||||
let worker_path = options
|
||||
.root
|
||||
.join(WORKERS_DIR)
|
||||
.join(worker_id().to_string())
|
||||
.join(WORKER_FILE);
|
||||
let before = fs::read(&worker_path).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
assert_eq!(fs::read(worker_path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_uninitialized_untrusted_live_and_partial_destination() {
|
||||
let (_source, _, template) = source();
|
||||
let (_target, options) = destination(&template);
|
||||
let held = acquire_runtime_store_owner_lock(&options.root).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
assert_fresh(&options);
|
||||
drop(held);
|
||||
initialize(&options.root, "wrong-runtime", WORKSPACE, NEW_URL);
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
initialize(&options.root, RUNTIME, WORKSPACE, "http://127.0.0.1:23456");
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
fs::remove_file(options.root.join("auth.toml")).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
initialize(&options.root, RUNTIME, WORKSPACE, NEW_URL);
|
||||
fs::create_dir(options.root.join(WORKERS_DIR)).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extraction_rejects_nonstopped_pending_schema_and_metadata_mismatch() {
|
||||
for change in [
|
||||
"running",
|
||||
"pending",
|
||||
"guard-pending",
|
||||
"attachment",
|
||||
"schema",
|
||||
"worker-id",
|
||||
"metadata-workspace",
|
||||
"metadata-session",
|
||||
"artifact",
|
||||
"secret",
|
||||
"url",
|
||||
"path",
|
||||
] {
|
||||
let (_source, worker, _) = source();
|
||||
let mut aggregate: WorkerAggregateRecord =
|
||||
decode(&fs::read(worker.join(WORKER_FILE)).unwrap()).unwrap();
|
||||
match change {
|
||||
"running" => aggregate.status = WorkerStatus::Running,
|
||||
"pending" => {
|
||||
aggregate.execution_state = WorkerExecutionStateRecord::ReconciliationRequired(
|
||||
PersistedWorkerLifecycleOperation::Restore(PersistedWorkerRestoreOperation {
|
||||
operation_id: WorkerLifecycleOperationId::new(),
|
||||
mode: PersistedWorkerRestoreMode::Explicit,
|
||||
request: request(),
|
||||
binding: None,
|
||||
last_settled_status: WorkerStatus::Stopped,
|
||||
}),
|
||||
)
|
||||
}
|
||||
"guard-pending" => aggregate.restore_guard = serde_json::from_str(r#"{"observation_token":"old-token","active_request_id":"pending-restore","owners":{}}"#).unwrap(),
|
||||
"attachment" => aggregate.logical_workdir_attachments.push(LogicalWorkdirAttachment {
|
||||
alias: workdir::WorkdirAttachmentAlias::new("source").unwrap(),
|
||||
working_directory_id: "old-workdir".into(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
}),
|
||||
"schema" => aggregate.schema_version += 1,
|
||||
"worker-id" => aggregate.worker_id = WorkerId::now_v7(),
|
||||
"metadata-workspace" | "metadata-session" => {
|
||||
let mut metadata: WorkerMetadata =
|
||||
decode(&fs::read(worker.join(WORKER_METADATA_FILE)).unwrap()).unwrap();
|
||||
if change == "metadata-workspace" {
|
||||
metadata.workspace_id = Some("wrong-workspace".into());
|
||||
} else {
|
||||
metadata.active.as_mut().unwrap().session_id = uuid::Uuid::now_v7();
|
||||
}
|
||||
atomic_write_json(
|
||||
&worker.join(WORKER_METADATA_FILE),
|
||||
&metadata,
|
||||
"test mismatch",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"artifact" => {
|
||||
fs::create_dir_all(worker.join("session/artifacts/paste")).unwrap();
|
||||
fs::write(
|
||||
worker.join("session/artifacts/paste/payload"),
|
||||
"unsupported",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"secret" | "url" | "path" => {
|
||||
let segment = fs::read_dir(worker.join("session/segments"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let replacement = match change {
|
||||
"secret" => "Bearer secret",
|
||||
"url" => "https://old-host.invalid",
|
||||
_ => "/old/host/path",
|
||||
};
|
||||
let text = fs::read_to_string(&segment)
|
||||
.unwrap()
|
||||
.replace("artificial fixture reply", replacement);
|
||||
fs::write(segment, text).unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
atomic_write_json(&worker.join(WORKER_FILE), &aggregate, "test aggregate").unwrap();
|
||||
assert!(
|
||||
StoppedWorkerTemplate::extract(&worker).is_err(),
|
||||
"accepted {change}"
|
||||
);
|
||||
}
|
||||
let (_source, worker, _) = source();
|
||||
let held =
|
||||
acquire_runtime_store_owner_lock(worker.parent().unwrap().parent().unwrap()).unwrap();
|
||||
assert!(StoppedWorkerTemplate::extract(&worker).is_err());
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_manifest_and_memory_settings_are_required() {
|
||||
let (_source, _, template) = source();
|
||||
let (_target, options) = destination(&template);
|
||||
let mut manifest = resolved();
|
||||
manifest.worker.name = "wrong-worker".into();
|
||||
assert!(template.install(&options, request(), &manifest).is_err());
|
||||
let mut binding = request();
|
||||
binding.memory_settings = None;
|
||||
assert!(template.install(&options, binding, &resolved()).is_err());
|
||||
let mut binding = request();
|
||||
binding.memory_settings.as_mut().unwrap().workspace_id = "other-workspace".into();
|
||||
assert!(template.install(&options, binding, &resolved()).is_err());
|
||||
assert_fresh(&options);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settled_checkpoints_preserve_receipts_but_refuse_pending_work() {
|
||||
let mut checkpoint = settled_checkpoint();
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_ok());
|
||||
checkpoint.pending.push(1);
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
|
||||
checkpoint.pending.clear();
|
||||
checkpoint.activating = Some(1);
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
|
||||
checkpoint.activating = None;
|
||||
checkpoint.invocation_receipts.push(1);
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
|
||||
checkpoint.invocation_receipts.clear();
|
||||
checkpoint.invocation_recovery_blocked = true;
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
|
||||
}
|
||||
|
||||
// Deliberately corrupt only test-owned synthetic source files, never the lab.
|
||||
fn assert_rejected_session_bytes(worker: &Path, template: &StoppedWorkerTemplate, bytes: Vec<u8>) {
|
||||
let path = worker.join(format!("session/segments/{}.jsonl", template.segment_id()));
|
||||
fs::write(path, &bytes).unwrap();
|
||||
let extraction_error = StoppedWorkerTemplate::extract(worker)
|
||||
.err()
|
||||
.expect("invalid source accepted");
|
||||
assert!(
|
||||
!extraction_error
|
||||
.to_string()
|
||||
.contains("synthetic-discarded-material")
|
||||
);
|
||||
let (_target, options) = destination(template);
|
||||
let mut invalid = template.clone();
|
||||
invalid.segment_jsonl = bytes;
|
||||
let installation_error = invalid
|
||||
.install(&options, request(), &resolved())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
!installation_error
|
||||
.to_string()
|
||||
.contains("synthetic-discarded-material")
|
||||
);
|
||||
assert_fresh(&options);
|
||||
}
|
||||
|
||||
fn synthetic_logs(template: &StoppedWorkerTemplate) -> Vec<LogEntry> {
|
||||
template
|
||||
.segment_jsonl
|
||||
.split(|byte| *byte == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| serde_json::from_slice(line).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn encode_synthetic_logs(logs: &[LogEntry]) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
for log in logs {
|
||||
serde_json::to_writer(&mut bytes, log).unwrap();
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_system_prompt_matches_worker_restore_rejection() {
|
||||
let (_source, worker_dir, template) = source();
|
||||
let mut logs = synthetic_logs(&template);
|
||||
assert!(session_store::collect_state(&logs).system_prompt.is_some());
|
||||
let LogEntry::AnnotatedSegmentStart { system_prompt, .. } = &mut logs[0] else {
|
||||
panic!("missing seed")
|
||||
};
|
||||
*system_prompt = None;
|
||||
assert_rejected_session_bytes(&worker_dir, &template, encode_synthetic_logs(&logs));
|
||||
// The owning Worker constructor rejects this before model-client creation,
|
||||
// allocation, or execution. Do not start a controller or submit any input.
|
||||
let store = session_store::CombinedStore::new(
|
||||
WorkerSessionStore::open_read_only(worker_dir.join("session")).unwrap(),
|
||||
session_store::WorkerAggregateStore::open_read_only(&worker_dir, template.worker_name())
|
||||
.unwrap(),
|
||||
);
|
||||
let restored = worker::Worker::restore_from_manifest(
|
||||
template.session_id(),
|
||||
template.segment_id(),
|
||||
resolved(),
|
||||
store,
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
restored,
|
||||
Err(worker::WorkerError::SegmentSystemPromptMissing { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_submission_and_notification_receipts_fail_extraction_and_install() {
|
||||
let (_source, worker, template) = source();
|
||||
for notification in [false, true] {
|
||||
let mut checkpoint = settled_checkpoint();
|
||||
if notification {
|
||||
checkpoint.notification_receipts =
|
||||
vec![ReceiptFixture::Malformed(1), ReceiptFixture::Malformed(2)];
|
||||
} else {
|
||||
checkpoint.receipts = vec![ReceiptFixture::Malformed(1), ReceiptFixture::Malformed(2)];
|
||||
}
|
||||
let mut logs = synthetic_logs(&template);
|
||||
for log in &mut logs {
|
||||
if let LogEntry::AnnotatedUserInput { extensions, .. } = log {
|
||||
*extensions = vec![checkpoint_extension(&checkpoint)];
|
||||
}
|
||||
}
|
||||
assert_rejected_session_bytes(&worker, &template, encode_synthetic_logs(&logs));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_at_every_retained_record_depth_are_rejected_without_secret_diagnostics() {
|
||||
let (_source, worker, template) = source();
|
||||
let original = String::from_utf8(template.segment_jsonl.clone()).unwrap();
|
||||
// These values are harmless synthetic markers, not lab/private material.
|
||||
// The compatibility decoder normally discards many of these fields.
|
||||
let locations = [
|
||||
r#""kind":"annotated_segment_start""#,
|
||||
r#""kind":"invoke""#,
|
||||
r#""kind":"annotated_user_input""#,
|
||||
r#""kind":"annotated_assistant_item""#,
|
||||
r#""kind":"llm_usage""#,
|
||||
r#""kind":"turn_end""#,
|
||||
r#""kind":"run_completed""#,
|
||||
r#""max_tokens":null"#,
|
||||
r#""kind":"message","role":"assistant""#,
|
||||
r#""kind":"text","text":"artificial fixture reply""#,
|
||||
r#""kind":"text","content":"Harmless artificial fixture input.""#,
|
||||
r#""entry_id":"fixed-entry-id""#,
|
||||
r#""kind":"model_output""#,
|
||||
r#""worker_id":"01a1657d-b762-7791-87ae-a49f28cadbce""#,
|
||||
r#""domain":"worker.pending_activations.v1""#,
|
||||
r#""next_activation_sequence":2"#,
|
||||
r#""submission_request_id":"completed-submission-request""#,
|
||||
r#""notification_request_id":"completed-notification-request""#,
|
||||
];
|
||||
for location in locations {
|
||||
assert!(original.contains(location));
|
||||
for extra in [
|
||||
r#", "reasoning":{"text":"synthetic-discarded-material"}"#,
|
||||
r#", "encrypted_content":"synthetic-discarded-material""#,
|
||||
] {
|
||||
let altered = original.replacen(location, &format!("{location}{extra}"), 1);
|
||||
assert_rejected_session_bytes(&worker, &template, altered.into_bytes());
|
||||
}
|
||||
}
|
||||
let duplicate = original.replacen(
|
||||
r#""role":"assistant""#,
|
||||
r#""role":"assistant","role":"assistant""#,
|
||||
1,
|
||||
);
|
||||
assert_rejected_session_bytes(&worker, &template, duplicate.into_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_feature_payloads_cannot_bypass_strict_session_validation() {
|
||||
let (_source, worker, template) = source();
|
||||
let mut logs = synthetic_logs(&template);
|
||||
for log in &mut logs {
|
||||
if let LogEntry::AnnotatedUserInput { history, .. } = log {
|
||||
if let session_store::LoggedItem::Message { role, .. } = &mut history[0].item {
|
||||
*role = session_store::LoggedRole::System;
|
||||
}
|
||||
history[0].metadata.input_metadata = Some(
|
||||
session_store::HistoryInputMetadata::new(
|
||||
session_store::HistoryInputUsePolicy {
|
||||
recording_material: session_store::InputEvidenceUse::Exclude,
|
||||
memory_evidence: session_store::InputEvidenceUse::Exclude,
|
||||
observation: session_store::InputObservationUse::Hidden,
|
||||
compaction: session_store::InputCompactionUse::Retain,
|
||||
},
|
||||
Some(
|
||||
session_store::FeatureInputPayload::encode(
|
||||
"fixture.test",
|
||||
1,
|
||||
&BTreeMap::from([("reasoning", "harmless synthetic payload")]),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_rejected_session_bytes(&worker, &template, encode_synthetic_logs(&logs));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_source_and_destination_symlinks() {
|
||||
let (_source, worker, template) = source();
|
||||
let (_target, options) = destination(&template);
|
||||
std::os::unix::fs::symlink(worker.join(WORKER_FILE), options.root.join("linked")).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
fs::remove_file(options.root.join("linked")).unwrap();
|
||||
std::os::unix::fs::symlink(worker.join(WORKER_FILE), worker.join("runs/link")).unwrap();
|
||||
assert!(StoppedWorkerTemplate::extract(&worker).is_err());
|
||||
}
|
||||
|
||||
/// Opt-in only: source path supplied explicitly; assertions never display content.
|
||||
#[test]
|
||||
#[ignore = "requires the approved artificial lab aggregate via YOI_STOPPED_FIXTURE_SOURCE"]
|
||||
fn approved_lab_extract_install() {
|
||||
let path = PathBuf::from(
|
||||
std::env::var_os("YOI_STOPPED_FIXTURE_SOURCE").expect("explicit source required"),
|
||||
);
|
||||
let template = StoppedWorkerTemplate::extract(&path).unwrap();
|
||||
let (_target, options) = destination(&template);
|
||||
let mut bind = request();
|
||||
bind.worker_id = template.worker_id();
|
||||
bind.workspace_api.as_mut().unwrap().workspace_id = template.workspace_id().into();
|
||||
bind.memory_settings.as_mut().unwrap().workspace_id = template.workspace_id().into();
|
||||
let mut manifest = resolved();
|
||||
manifest.worker.name = template.worker_name();
|
||||
template.install(&options, bind, &manifest).unwrap();
|
||||
let installed = options
|
||||
.root
|
||||
.join(WORKERS_DIR)
|
||||
.join(template.worker_id().to_string());
|
||||
for relative in [
|
||||
"session/session.json".to_string(),
|
||||
format!("session/segments/{}.jsonl", template.segment_id()),
|
||||
] {
|
||||
assert!(
|
||||
fs::read(path.join(&relative)).unwrap() == fs::read(installed.join(relative)).unwrap()
|
||||
);
|
||||
}
|
||||
let runtime = crate::Runtime::with_fs_store(options).unwrap();
|
||||
assert_eq!(runtime.list_workers().unwrap().len(), 1);
|
||||
assert_eq!(runtime.summary().unwrap().active_worker_count, 0);
|
||||
}
|
||||
@@ -1389,6 +1389,15 @@ fn bind_workspace_memory_settings(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only validation shared with the offline, feature-gated fixture installer.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub(crate) fn validate_fixture_restore_metadata(
|
||||
metadata: &session_store::WorkerMetadata,
|
||||
request: &CreateWorkerRequest,
|
||||
) -> Result<(), String> {
|
||||
ProfileRuntimeWorkerFactory::manifest_for_restore(metadata, request).map(|_| ())
|
||||
}
|
||||
|
||||
fn validate_worker_memory_settings(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
request: &CreateWorkerRequest,
|
||||
|
||||
@@ -71,9 +71,10 @@ pub use session_history::{
|
||||
};
|
||||
pub use shared_state::WorkerSharedState;
|
||||
pub use worker::{
|
||||
LocalWorkingDirectory, SubjektivSessionAttributionLifecycle, Worker, WorkerError,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest,
|
||||
WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
|
||||
LocalWorkingDirectory, ReviewerChildWorkspaceClient, ReviewerContext,
|
||||
SubjektivSessionAttributionLifecycle, Worker, WorkerError, WorkerFilesystemAuthority,
|
||||
WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest, WorkspaceBinaryResponse,
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
|
||||
WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest,
|
||||
marker_workspace_client, unavailable_workspace_client,
|
||||
|
||||
@@ -1332,15 +1332,28 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||
mut request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id().unwrap_or_default();
|
||||
let ticket_id = &self.context.ticket_id;
|
||||
let ticket_query_path = format!("/api/w/{workspace_id}/tickets/query");
|
||||
let ticket_show_path = format!("/api/w/{workspace_id}/tickets/{ticket_id}/show");
|
||||
let ticket_show_prefix = format!("/api/w/{workspace_id}/tickets/");
|
||||
// ShowTicket uses POST for bounded read parameters. Like Ticket query
|
||||
// and GET reads, it must allow related Tickets through the bound Host's
|
||||
// read authority. Only review submission is restricted to the attested MR.
|
||||
// Match one literal reference segment, not arbitrary POST routes.
|
||||
let is_ticket_show = request
|
||||
.path
|
||||
.strip_prefix(&ticket_show_prefix)
|
||||
.and_then(|path| path.strip_suffix("/show"))
|
||||
.is_some_and(|reference| {
|
||||
!reference.is_empty()
|
||||
&& reference
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
});
|
||||
let merge_request_id = &self.context.merge_request_id;
|
||||
let review_path =
|
||||
format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}/reviews");
|
||||
let read_allowed = request.method == WorkspaceRequestMethod::Get
|
||||
|| (request.method == WorkspaceRequestMethod::Post
|
||||
&& (request.path == ticket_query_path || request.path == ticket_show_path));
|
||||
&& (request.path == ticket_query_path || is_ticket_show));
|
||||
if request.method == WorkspaceRequestMethod::Post && request.path == review_path {
|
||||
let body = request.body.take().ok_or_else(|| {
|
||||
WorkspaceClientError::Request("review submission requires a JSON body".to_string())
|
||||
@@ -1868,16 +1881,11 @@ mod reviewer_client_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_child_client_rejects_other_ticket_and_mutation_posts() {
|
||||
fn reviewer_child_client_rejects_mutation_posts() {
|
||||
let inner: Arc<dyn WorkspaceClient> = Arc::new(RecordingWorkspaceClient::default());
|
||||
let client = reviewer_client(inner);
|
||||
|
||||
for request in [
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/ws/tickets/T2/show",
|
||||
"{}".to_string(),
|
||||
),
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/ws/tickets/T1/comments",
|
||||
@@ -1894,6 +1902,54 @@ mod reviewer_client_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_ticket_reads_reject_route_injection_and_mutations_without_transport() {
|
||||
let inner = Arc::new(RecordingWorkspaceClient::default());
|
||||
let client = reviewer_client(inner.clone());
|
||||
for path in [
|
||||
"/api/w/other/tickets/T-1/show",
|
||||
"/api/w/ws/tickets//show",
|
||||
"/api/w/ws/tickets/T-1/comments",
|
||||
"/api/w/ws/tickets/T-1/posts",
|
||||
"/api/w/ws/tickets/T-1/show/",
|
||||
"/api/w/ws/tickets/T-1/show?query=1",
|
||||
"/api/w/ws/tickets/T-1?query=1/show",
|
||||
"/api/w/ws/tickets/T-1#fragment/show",
|
||||
"/api/w/ws/tickets/../show",
|
||||
"/api/w/ws/tickets/T-1/../T-2/show",
|
||||
"/api/w/ws/tickets/T%2D1/show",
|
||||
"/api/w/ws/tickets/T-1%2fcomments/show",
|
||||
"/api/w/ws/merge-requests/MR2/reviews",
|
||||
] {
|
||||
assert!(
|
||||
client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
path,
|
||||
"{}",
|
||||
))
|
||||
.is_err(),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
for method in [
|
||||
WorkspaceRequestMethod::Put,
|
||||
WorkspaceRequestMethod::Patch,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
] {
|
||||
assert!(
|
||||
client
|
||||
.execute(WorkspaceRequest::json(
|
||||
method,
|
||||
"/api/w/ws/tickets/T-1/show",
|
||||
"{}"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(inner.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_child_client_injects_capability_only_for_attested_review() {
|
||||
let inner = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -10,6 +10,10 @@ autobins = false
|
||||
name = "yoi-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
# Offline fixture preparation only; build product binaries without this feature.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
api-macros = { workspace = true, features = ["axum"] }
|
||||
async-trait.workspace = true
|
||||
|
||||
@@ -8866,7 +8866,14 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect_err("closed session must reject local operation without another request");
|
||||
assert!(matches!(error, WorkdirError::Unavailable(_)));
|
||||
assert!(matches!(
|
||||
error.classification_source(),
|
||||
WorkdirError::Unavailable(_)
|
||||
));
|
||||
assert_eq!(
|
||||
error.failure_reason(),
|
||||
Some(workdir::WorkdirFailureReason::SessionClosed)
|
||||
);
|
||||
server.join().expect("mock remote server finished");
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ pub mod runtime_subscription;
|
||||
pub mod server;
|
||||
pub mod skills;
|
||||
pub mod store;
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use store::fixture_support as test_support;
|
||||
pub mod subjektiv;
|
||||
#[cfg(test)]
|
||||
mod ticket_browse_performance_tests;
|
||||
|
||||
@@ -37791,9 +37791,13 @@ mod tests {
|
||||
)))
|
||||
.unwrap();
|
||||
}
|
||||
let error = write
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("mismatched or lost mutation response must remain outcome-unknown");
|
||||
assert!(matches!(
|
||||
write.await.unwrap(),
|
||||
Err(workdir::WorkdirError::OutcomeUnknown(_))
|
||||
error.classification_source(),
|
||||
workdir::WorkdirError::OutcomeUnknown(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,14 @@ struct TicketSurface {
|
||||
}
|
||||
impl TicketSurface {
|
||||
async fn new(root: &Path, client: Arc<TicketRouterClient>, wip: bool) -> Self {
|
||||
Self::with_access(root, client, wip, TicketFeatureAccess::workflow()).await
|
||||
}
|
||||
async fn with_access(
|
||||
root: &Path,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
wip: bool,
|
||||
access: TicketFeatureAccess,
|
||||
) -> Self {
|
||||
let manifest = worker::WorkerManifest::from_toml("[worker]\nname=\"ticket-tools\"\n[model]\nscheme=\"anthropic\"\nmodel_id=\"unused\"\n[engine]\n[scope]\nallow=[]\n").unwrap();
|
||||
let engine =
|
||||
agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
||||
@@ -138,22 +146,18 @@ impl TicketSurface {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let report =
|
||||
host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module(
|
||||
TicketFeature::new(client.clone(), TicketFeatureAccess::workflow()),
|
||||
));
|
||||
let report = host.install_features(
|
||||
worker::feature::FeatureRegistryBuilder::new()
|
||||
.with_module(TicketFeature::new(client.clone(), access)),
|
||||
);
|
||||
assert!(!report.has_errors());
|
||||
let handle = host.engine().tool_server_handle();
|
||||
handle.flush_pending();
|
||||
let tools = [
|
||||
"TicketAssignmentTrackingQuery",
|
||||
"TicketAssignmentAnswer",
|
||||
"TicketImplementationResult",
|
||||
"TicketImplementationResultQuery",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| (name.into(), handle.get_tool(name).unwrap().1))
|
||||
.collect();
|
||||
let tools = access
|
||||
.tool_names()
|
||||
.into_iter()
|
||||
.map(|name| (name.into(), handle.get_tool(name).unwrap().1))
|
||||
.collect();
|
||||
if !wip {
|
||||
return Self { tools, wip };
|
||||
}
|
||||
@@ -162,7 +166,7 @@ impl TicketSurface {
|
||||
worker::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||
&mut mounts,
|
||||
client,
|
||||
TicketFeatureAccess::workflow(),
|
||||
access,
|
||||
None,
|
||||
&route,
|
||||
)
|
||||
@@ -189,8 +193,9 @@ impl TicketSurface {
|
||||
if !self.wip {
|
||||
return execute_tool(self.tools[name].clone(), input, context).await;
|
||||
}
|
||||
let path = format!("/tickets/{}", input["ticket"].as_str().unwrap());
|
||||
input.as_object_mut().unwrap().remove("ticket");
|
||||
let reference_field = if name == "ShowTicket" { "id" } else { "ticket" };
|
||||
let path = format!("/tickets/{}", input[reference_field].as_str().unwrap());
|
||||
input.as_object_mut().unwrap().remove(reference_field);
|
||||
// Observe the real item representation before invoking a mutation. Tracking
|
||||
// reads alone do not establish the Ticket WIP object validator. Explicit
|
||||
// refresh also reconciles stale observations after an unknown result;
|
||||
@@ -201,6 +206,9 @@ impl TicketSurface {
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await?;
|
||||
if name == "ShowTicket" {
|
||||
return execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":"read","arguments":input}), context).await;
|
||||
}
|
||||
execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":"read","arguments":{}}), ToolExecutionContext::direct()).await?;
|
||||
let inspected = execute_tool(
|
||||
self.tools["Inspect"].clone(),
|
||||
@@ -364,6 +372,210 @@ async fn ticket_public_roundtrip_observed_answer_replays_without_changing_waitin
|
||||
}
|
||||
}
|
||||
|
||||
// Reviewer policy, tool projection, reference resolution, authentication and
|
||||
// persistence remain production code. Only the synchronous HTTP transport is
|
||||
// replaced by signed in-process router dispatch. Child spawning and MR attestation
|
||||
// have their own suites; this fixture does not claim to exercise those boundaries.
|
||||
fn reviewer_client(inner: Arc<TicketRouterClient>, bound_ticket: &str) -> Arc<dyn WorkspaceClient> {
|
||||
Arc::new(worker::ReviewerChildWorkspaceClient::new(
|
||||
inner,
|
||||
worker::ReviewerContext {
|
||||
ticket_id: bound_ticket.into(),
|
||||
merge_request_id: "unsubmitted-review-candidate".into(),
|
||||
},
|
||||
"unused-review-capability".into(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reviewer_reads_saved_related_tickets_by_key_and_id_and_pages_their_threads() {
|
||||
for wip in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _runtime, identity, source, bound_id) = tracking_fixture(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut related = ticket::NewTicket::new("Related requirements");
|
||||
related.body = ticket::MarkdownText::new("Requirements from a different Ticket");
|
||||
let related = backend.create(related).unwrap();
|
||||
backend
|
||||
.add_ticket_relation(
|
||||
bound_id.clone().into(),
|
||||
ticket::NewTicketRelation {
|
||||
kind: ticket::TicketRelationKind::DependsOn,
|
||||
target: related.id.clone(),
|
||||
note: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for body in ["Earlier decision", "Latest decision"] {
|
||||
backend
|
||||
.add_event(
|
||||
related.id.clone().into(),
|
||||
ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, body),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let bound = backend.show(bound_id.clone().into()).unwrap();
|
||||
let related_before = backend.show(related.id.clone().into()).unwrap();
|
||||
let bound_key = bound.meta.resource_key.as_deref().unwrap();
|
||||
let related_key = related.resource_key.as_deref().unwrap();
|
||||
let transport = ticket_client(&api, identity, &source);
|
||||
// One client must retain its review binding while following related Tickets.
|
||||
let client = reviewer_client(transport.clone(), &bound_id);
|
||||
let surface =
|
||||
TicketSurface::with_access(dir.path(), client, wip, TicketFeatureAccess::review())
|
||||
.await;
|
||||
for (reference, expected_key, expected_title, expected_body) in [
|
||||
(
|
||||
bound_key,
|
||||
bound_key,
|
||||
"Tracking authority",
|
||||
bound.document.body.as_str(),
|
||||
),
|
||||
(
|
||||
related_key,
|
||||
related_key,
|
||||
"Related requirements",
|
||||
related_before.document.body.as_str(),
|
||||
),
|
||||
(
|
||||
related.id.as_str(),
|
||||
related_key,
|
||||
"Related requirements",
|
||||
related_before.document.body.as_str(),
|
||||
),
|
||||
(
|
||||
bound_id.as_str(),
|
||||
bound_key,
|
||||
"Tracking authority",
|
||||
bound.document.body.as_str(),
|
||||
),
|
||||
] {
|
||||
let shown = surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id":reference}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(shown["ticket"], expected_key);
|
||||
assert_eq!(shown["title"], expected_title);
|
||||
assert_eq!(shown["body"], expected_body);
|
||||
if expected_key == bound_key {
|
||||
assert!(
|
||||
shown["relations"]["outgoing"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|relation| relation["ticket"] == related_key
|
||||
&& relation["kind"] == "depends_on")
|
||||
);
|
||||
}
|
||||
}
|
||||
let first = surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id":related_key,"event_limit":1}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first["thread"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(first["thread"][0]["body"], "Latest decision");
|
||||
assert_eq!(first["event_page"]["has_more"], true);
|
||||
let second = surface.call("ShowTicket", serde_json::json!({"id":related_key,"event_limit":1,"event_cursor":first["event_page"]["next_cursor"]}), ToolExecutionContext::direct()).await.unwrap();
|
||||
assert_eq!(second["thread"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(second["thread"][0]["body"], "Earlier decision");
|
||||
assert_eq!(backend.show(bound_id.into()).unwrap(), bound);
|
||||
assert_eq!(backend.show(related.id.into()).unwrap(), related_before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reviewer_read_uses_live_backend_membership_and_cannot_mutate_saved_ticket() {
|
||||
for wip in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let before = backend.show(ticket.clone().into()).unwrap();
|
||||
let transport = ticket_client(&api, identity, &source);
|
||||
let client = reviewer_client(transport.clone(), &ticket);
|
||||
let surface = TicketSurface::with_access(
|
||||
dir.path(),
|
||||
client.clone(),
|
||||
wip,
|
||||
TicketFeatureAccess::review(),
|
||||
)
|
||||
.await;
|
||||
surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id":ticket}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let request_count = transport.requests.lock().unwrap().len();
|
||||
for (method, path, body) in [
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/comments"),
|
||||
serde_json::json!({"body":"unauthorized comment"}),
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Patch,
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}"),
|
||||
serde_json::json!({"title":"unauthorized title"}),
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/another-workspace/tickets/{ticket}/show"),
|
||||
serde_json::json!({}),
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests/other-candidate/reviews"),
|
||||
serde_json::json!({"verdict":"approved"}),
|
||||
),
|
||||
] {
|
||||
let client = client.clone();
|
||||
let error = tokio::task::spawn_blocking(move || {
|
||||
client.execute(WorkspaceRequest::json(method, path, body.to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("read-only"), "{error}");
|
||||
}
|
||||
assert_eq!(transport.requests.lock().unwrap().len(), request_count);
|
||||
assert_eq!(backend.show(ticket.clone().into()).unwrap(), before);
|
||||
// Keep a valid Runtime signature but select an unregistered Worker.
|
||||
// This must fail at the real Backend, not the Reviewer route filter.
|
||||
*transport.source.lock().unwrap() =
|
||||
RuntimeWorkerRef::new(source.runtime_id, uuid::Uuid::now_v7().to_string());
|
||||
let error = surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id":ticket}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("401"), "{error}");
|
||||
assert!(
|
||||
transport
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.skip(request_count)
|
||||
.any(|(_, status, _)| *status == 401)
|
||||
);
|
||||
assert_eq!(backend.show(ticket.into()).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
fn report_counts(api: &WorkspaceApi) -> (i64, i64) {
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
(
|
||||
|
||||
@@ -2589,6 +2589,10 @@ pub struct SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor,
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
#[path = "test_support.rs"]
|
||||
pub mod fixture_support;
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
@@ -2900,6 +2904,33 @@ impl SqliteWorkspaceStore {
|
||||
request_fingerprint: &str,
|
||||
singleton_key: Option<&str>,
|
||||
current_memory_settings: &WorkspaceMemorySettingsRecord,
|
||||
) -> Result<WorkerCreateReservation> {
|
||||
self.reserve_worker_create_with(
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
allocation_key,
|
||||
request_fingerprint,
|
||||
singleton_key,
|
||||
current_memory_settings,
|
||||
|_| Ok(None),
|
||||
|_, _| Ok(()),
|
||||
)
|
||||
}
|
||||
|
||||
// Keep reservation validation, Memory binding and writes in one transaction. The
|
||||
// nondefault test-support module supplies fixed identity / offline catalog hooks;
|
||||
// ordinary creation always supplies a newly generated ID and no completion hook.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn reserve_worker_create_with(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
allocation_key: &str,
|
||||
request_fingerprint: &str,
|
||||
singleton_key: Option<&str>,
|
||||
current_memory_settings: &WorkspaceMemorySettingsRecord,
|
||||
prepare: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<Option<WorkerId>>,
|
||||
complete: impl FnOnce(&rusqlite::Transaction<'_>, &WorkerCreateReservation) -> Result<()>,
|
||||
) -> Result<WorkerCreateReservation> {
|
||||
if allocation_key.trim().is_empty() || request_fingerprint.trim().is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
@@ -2912,6 +2943,7 @@ impl SqliteWorkspaceStore {
|
||||
validate_workspace_memory_settings_record(current_memory_settings, workspace_id)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let fixed_worker_id = prepare(&tx)?;
|
||||
let existing = tx
|
||||
.query_row(
|
||||
"SELECT worker_id, runtime_id, request_fingerprint, create_fingerprint, \
|
||||
@@ -3094,7 +3126,7 @@ impl SqliteWorkspaceStore {
|
||||
validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?;
|
||||
let create_fingerprint =
|
||||
bound_worker_create_fingerprint(request_fingerprint, &snapshot);
|
||||
let worker_id = WorkerId::now_v7();
|
||||
let worker_id = fixed_worker_id.unwrap_or_else(WorkerId::now_v7);
|
||||
let worker_id_text = worker_id.to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let singleton_generation = match singleton_owner.as_ref() {
|
||||
@@ -3183,13 +3215,15 @@ impl SqliteWorkspaceStore {
|
||||
));
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(WorkerCreateReservation {
|
||||
let reservation = WorkerCreateReservation {
|
||||
worker_id,
|
||||
create_fingerprint,
|
||||
memory_settings: snapshot,
|
||||
singleton,
|
||||
})
|
||||
};
|
||||
complete(&tx, &reservation)?;
|
||||
tx.commit()?;
|
||||
Ok(reservation)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
//! Offline Backend fixture preparation, available only with `test-support`.
|
||||
//!
|
||||
//! Use a new isolated database and the existing account, Workspace signing-identity /
|
||||
//! `ControlPlaneStore::create_workspace_bootstrap`, and Runtime binding APIs first.
|
||||
//! Generate fresh account credentials and signing keys for each environment; no fixture
|
||||
//! credentials are accepted here. Install Profile configuration through the existing
|
||||
//! config-source mechanisms before starting the product processes.
|
||||
//!
|
||||
//! This module seeds Backend ownership only. It neither writes Runtime storage nor
|
||||
//! launches a Worker, fabricates Runtime observations, changes authorization, or sends
|
||||
//! initial input. The caller must separately install the matching stopped Runtime
|
||||
//! fixture. Build product binaries in a separate Cargo invocation **without** this
|
||||
//! feature (Cargo features unify within an invocation).
|
||||
|
||||
use super::*;
|
||||
use crate::hosts::{WorkerSpawnIntent, WorkerSpawnRequest, worker_spawn_create_fingerprint};
|
||||
use worker_runtime::catalog::ProfileSelector;
|
||||
|
||||
/// One ordinary, attachment-free Worker from an externally validated fixture.
|
||||
///
|
||||
/// IDs must match the Runtime fixture. `request` is the unresolved canonical create
|
||||
/// intent, not a Runtime launch request containing credentials or execution grants.
|
||||
/// The Profile selector must already be installed by the harness. `initial_submit`
|
||||
/// must be empty: saved Session contents belong to the Runtime fixture, not new input.
|
||||
/// Singleton/service, Ticket-assigned and attachment-bearing Workers are deliberately
|
||||
/// unsupported.
|
||||
pub struct FixedWorkerSeed<'a> {
|
||||
pub workspace_id: &'a str,
|
||||
pub runtime_id: &'a str,
|
||||
pub worker_id: WorkerId,
|
||||
pub allocation_key: &'a str,
|
||||
pub request: &'a WorkerSpawnRequest,
|
||||
}
|
||||
|
||||
/// Insert a fixed Worker catalog entry and completed creation reservation atomically.
|
||||
///
|
||||
/// Uses the normal reservation validators, authoritative Memory snapshot, fingerprint
|
||||
/// binding, resource-key allocator and immediate transaction. Even an exact repeat is
|
||||
/// rejected: this is not an upsert/import/recovery API. Existing allocations, Worker
|
||||
/// identities and residual lifecycle/attachment records are never adopted or repaired.
|
||||
/// Returns the creation binding needed to check the corresponding Runtime fixture.
|
||||
pub fn seed_fixed_worker(
|
||||
store: &SqliteWorkspaceStore,
|
||||
seed: FixedWorkerSeed<'_>,
|
||||
) -> Result<WorkerCreateReservation> {
|
||||
validate_identifier("workspace_id", seed.workspace_id)?;
|
||||
validate_identifier("runtime_id", seed.runtime_id)?;
|
||||
let request = seed.request;
|
||||
if !request.initial_submit.is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture Worker cannot contain initial_submit; offline seeding accepts no new input"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !matches!(request.intent, WorkerSpawnIntent::WorkspaceCoding)
|
||||
|| request.singleton_key.is_some()
|
||||
|| request.ticket_assignment.is_some()
|
||||
|| request.resolved_subjektiv_attached
|
||||
|| !request.workdir_attachment_requests.is_empty()
|
||||
|| !request.resolved_workdir_attachment_requests.is_empty()
|
||||
|| !request.resolved_workdir_attachments.is_empty()
|
||||
|| request.resolved_config_bundle.is_some()
|
||||
|| request.resolved_workspace_api.is_some()
|
||||
|| request.resolved_memory_settings.is_some()
|
||||
|| request.resolved_worker_observation_enabled
|
||||
|| !request.resolved_worker_observation_grants.is_empty()
|
||||
|| request.resolved_control_operation.is_some()
|
||||
|| request.resolved_execution_grant.is_some()
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture Worker must be ordinary unresolved create intent without singleton, assignments, attachments or grants".into(),
|
||||
));
|
||||
}
|
||||
// Builtin selectors already carry their canonical `builtin:` prefix.
|
||||
let profile = match &request.profile {
|
||||
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => name,
|
||||
};
|
||||
validate_non_empty("fixture Profile selector", profile)?;
|
||||
let worker_id = seed.worker_id.to_string();
|
||||
let display_name = request
|
||||
.requested_worker_name
|
||||
.as_deref()
|
||||
.unwrap_or(&worker_id);
|
||||
validate_non_empty("fixture Worker display name", display_name)?;
|
||||
let fingerprint = worker_spawn_create_fingerprint(request).map_err(Error::InvalidInput)?;
|
||||
let memory = store.get_workspace_memory_settings(seed.workspace_id)?;
|
||||
store.reserve_worker_create_with(
|
||||
seed.workspace_id,
|
||||
seed.runtime_id,
|
||||
seed.allocation_key,
|
||||
&fingerprint,
|
||||
None,
|
||||
&memory,
|
||||
|tx| {
|
||||
require_seed_target(tx, &seed)?;
|
||||
Ok(Some(seed.worker_id))
|
||||
},
|
||||
|tx, reservation| {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
tx.execute(
|
||||
"INSERT INTO worker_registry (
|
||||
workspace_id, runtime_id, worker_id, display_name, profile,
|
||||
retention_state, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, 'normal', ?6, ?6)",
|
||||
params![seed.workspace_id, seed.runtime_id, worker_id, display_name, profile, now],
|
||||
)?;
|
||||
let changed = tx.execute(
|
||||
"UPDATE worker_create_reservations SET state = 'created', updated_at = ?4
|
||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND state = 'reserved'",
|
||||
params![seed.workspace_id, seed.runtime_id, reservation.worker_id.to_string(), now],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(Error::Store("fixture creation reservation disappeared".into()));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn require_seed_target(tx: &rusqlite::Transaction<'_>, seed: &FixedWorkerSeed<'_>) -> Result<()> {
|
||||
let active: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces w
|
||||
JOIN workspace_runtime_bindings r ON r.workspace_id = w.workspace_id
|
||||
JOIN workspace_signing_identities s ON s.workspace_id = w.workspace_id
|
||||
WHERE w.workspace_id = ?1 AND w.state = 'active'
|
||||
AND r.runtime_id = ?2 AND r.revoked_at IS NULL
|
||||
AND r.authentication_mode = 'workspace_identity' AND s.state = 'active')",
|
||||
params![seed.workspace_id, seed.runtime_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !active {
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture requires an active bootstrapped Workspace and authenticated Runtime binding"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let worker_id = seed.worker_id.to_string();
|
||||
let allocation_exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM worker_create_reservations
|
||||
WHERE workspace_id = ?1 AND allocation_key = ?2)",
|
||||
params![seed.workspace_id, seed.allocation_key],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if allocation_exists {
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture Worker allocation already exists".into(),
|
||||
));
|
||||
}
|
||||
// Check by WorkerId, not just the proposed Runtime tuple: orphaned and terminal
|
||||
// ownership is still a collision. These tables are owned by this store schema.
|
||||
for table in [
|
||||
"worker_create_reservations",
|
||||
"worker_registry",
|
||||
"worker_registry_observations",
|
||||
"worker_registry_projection_removals",
|
||||
"worker_removal_operations",
|
||||
"worker_singleton_owners",
|
||||
"worker_workdir_links",
|
||||
"worker_restore_intents",
|
||||
"worker_tombstones",
|
||||
"worker_orphan_diagnostics",
|
||||
"worker_registry_projection_diagnostics",
|
||||
"worker_session_archives",
|
||||
"worker_diagnostics_archives",
|
||||
"ticket_assignment_worker_tombstones",
|
||||
] {
|
||||
let exists: bool = tx.query_row(
|
||||
&format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE worker_id = ?1)"),
|
||||
[&worker_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if exists {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"fixture Worker identity already exists in {table}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// A resource key without a catalog/reservation must not be silently reused.
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT resource_id FROM workspace_resource_keys WHERE resource_kind = 'worker'",
|
||||
)?;
|
||||
let resources = statement.query_map([], |row| row.get::<_, String>(0))?;
|
||||
for resource in resources {
|
||||
let resource = resource?;
|
||||
let existing = resource
|
||||
.is_ascii()
|
||||
.then(|| parse_worker_resource_id(&resource))
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidInput("unknown fixture Worker resource identity collision".into())
|
||||
})?;
|
||||
if existing.worker_id == worker_id {
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture Worker resource identity already exists".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hosts::{
|
||||
WorkerSpawnAcceptanceRequirement, WorkerSpawnWorkingDirectoryAttachmentRequest,
|
||||
WorkerSpawnWorkingDirectoryRequest,
|
||||
};
|
||||
use crate::workspace_signing_identity::{
|
||||
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
|
||||
};
|
||||
|
||||
const WORKSPACE: &str = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c501";
|
||||
const RUNTIME: &str = "fixture-runtime";
|
||||
const WORKER: &str = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c502";
|
||||
|
||||
// Exercises the same public bootstrap APIs the offline harness uses. All account
|
||||
// and key identities are newly generated, even across stores with fixed fixture IDs.
|
||||
fn bootstrap() -> SqliteWorkspaceStore {
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let account = AccountRecord {
|
||||
account_id: format!("fixture-account-{}", Uuid::now_v7()),
|
||||
kind: "user".into(),
|
||||
handle: format!("fixture-{}", Uuid::now_v7()),
|
||||
display_name: "Fixture owner".into(),
|
||||
created_at: "2026-10-24T00:00:00Z".into(),
|
||||
updated_at: "2026-10-24T00:00:00Z".into(),
|
||||
};
|
||||
store.upsert_account(&account).unwrap();
|
||||
let identities = WorkspaceSigningIdentityService::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
|
||||
);
|
||||
let (identity, operation) = identities
|
||||
.prepare_workspace_creation(
|
||||
"fixture-bootstrap",
|
||||
"fixture-bootstrap",
|
||||
WORKSPACE,
|
||||
&account.account_id,
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.create_workspace_bootstrap(
|
||||
&WorkspaceBootstrapRecord {
|
||||
operation_key: "fixture-bootstrap".into(),
|
||||
request_fingerprint: "fixture-bootstrap".into(),
|
||||
workspace: WorkspaceRecord {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
owner_account_id: account.account_id,
|
||||
display_name: "Fixture".into(),
|
||||
state: "active".into(),
|
||||
created_at: account.created_at,
|
||||
updated_at: account.updated_at,
|
||||
},
|
||||
repository: None,
|
||||
},
|
||||
&identity,
|
||||
&operation,
|
||||
)
|
||||
.unwrap();
|
||||
let runtime_key = worker_runtime::auth::RuntimeIdentityMaterial::generate(RUNTIME).unwrap();
|
||||
store
|
||||
.upsert_workspace_runtime_binding(
|
||||
WorkspaceRuntimeBinding {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
runtime_id: RUNTIME.into(),
|
||||
display_name: "Fixture Runtime".into(),
|
||||
base_url: "http://127.0.0.1:12345".into(),
|
||||
public_key: runtime_key.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: String::new(),
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
created_at: "2026-10-24T00:00:00Z".into(),
|
||||
updated_at: "2026-10-24T00:00:00Z".into(),
|
||||
revoked_at: None,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
}
|
||||
|
||||
fn request() -> WorkerSpawnRequest {
|
||||
WorkerSpawnRequest {
|
||||
intent: WorkerSpawnIntent::WorkspaceCoding,
|
||||
requested_worker_name: Some("Fixture Worker".into()),
|
||||
singleton_key: None,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
|
||||
profile: ProfileSelector::Builtin("builtin:coder".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachments: Vec::new(),
|
||||
resolved_config_bundle: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn seed(request: &WorkerSpawnRequest) -> FixedWorkerSeed<'_> {
|
||||
FixedWorkerSeed {
|
||||
workspace_id: WORKSPACE,
|
||||
runtime_id: RUNTIME,
|
||||
worker_id: WORKER.parse().unwrap(),
|
||||
allocation_key: "fixture-allocation",
|
||||
request,
|
||||
}
|
||||
}
|
||||
|
||||
fn worker() -> RuntimeWorkerRef {
|
||||
RuntimeWorkerRef::new(RUNTIME, WORKER)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_ids_are_isolated_and_seed_has_no_runtime_observation() {
|
||||
let a = bootstrap();
|
||||
let b = bootstrap();
|
||||
let request = request();
|
||||
let first = seed_fixed_worker(&a, seed(&request)).unwrap();
|
||||
let second = seed_fixed_worker(&b, seed(&request)).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.worker_id.to_string(), WORKER);
|
||||
assert_eq!(first.memory_settings.workspace_id, WORKSPACE);
|
||||
assert_eq!(first.memory_settings.language, "English");
|
||||
assert!(first.singleton.is_none());
|
||||
assert_ne!(
|
||||
a.get_workspace_signing_identity(WORKSPACE)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key,
|
||||
b.get_workspace_signing_identity(WORKSPACE)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key
|
||||
);
|
||||
assert_ne!(
|
||||
a.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key,
|
||||
b.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key
|
||||
);
|
||||
for store in [&a, &b] {
|
||||
assert_eq!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("W-1")
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.resolve_worker_resource_reference(WORKSPACE, "W-1")
|
||||
.unwrap(),
|
||||
Some(worker())
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.has_active_worker_create_reservation(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
);
|
||||
let registry = store
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(registry.profile.as_deref(), Some("builtin:coder"));
|
||||
assert_eq!(registry.display_name, "Fixture Worker");
|
||||
assert_eq!(registry.retention_state, "normal");
|
||||
let projection = store
|
||||
.with_conn(|conn| read_worker_registry_projection(conn, WORKSPACE, &worker()))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(projection.observation.is_none());
|
||||
assert!(projection.job.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_submit_is_refused_without_registration_or_allocation() {
|
||||
let store = bootstrap();
|
||||
let mut request = request();
|
||||
// Even an empty text segment is new input; the vector itself must be empty.
|
||||
for text in ["new input must not be seeded", ""] {
|
||||
request.initial_submit = vec![protocol::Segment::text(text)];
|
||||
let error = seed_fixed_worker(&store, seed(&request)).unwrap_err();
|
||||
assert!(
|
||||
matches!(error, Error::InvalidInput(ref message) if message.contains("initial_submit"))
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.has_active_worker_create_reservation(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
// The rejected attempts must not consume the allocation or resource counter.
|
||||
request.initial_submit.clear();
|
||||
seed_fixed_worker(&store, seed(&request)).unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("W-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_replay_changed_input_and_cross_runtime_identity_are_refused() {
|
||||
let store = bootstrap();
|
||||
let mut request = request();
|
||||
seed_fixed_worker(&store, seed(&request)).unwrap();
|
||||
let before = store.get_worker_registry(WORKSPACE, &worker()).unwrap();
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
request.requested_worker_name = Some("Must not overwrite".into());
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
let mut new_allocation = seed(&request);
|
||||
new_allocation.allocation_key = "another-allocation";
|
||||
assert!(seed_fixed_worker(&store, new_allocation).is_err());
|
||||
let mut same_allocation = seed(&request);
|
||||
same_allocation.worker_id = WorkerId::now_v7();
|
||||
assert!(seed_fixed_worker(&store, same_allocation).is_err());
|
||||
assert_eq!(
|
||||
store.get_worker_registry(WORKSPACE, &worker()).unwrap(),
|
||||
before
|
||||
);
|
||||
|
||||
let other = bootstrap();
|
||||
let mut record = before.unwrap();
|
||||
record.worker.runtime_id = "different-runtime".into();
|
||||
other.upsert_worker_registry(&record).unwrap();
|
||||
assert!(seed_fixed_worker(&other, seed(&request)).is_err());
|
||||
assert!(
|
||||
other
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_and_orphaned_resource_identities_are_not_adopted() {
|
||||
let store = bootstrap();
|
||||
let request = request();
|
||||
let memory = store.get_workspace_memory_settings(WORKSPACE).unwrap();
|
||||
let ordinary = store
|
||||
.reserve_worker_create(WORKSPACE, RUNTIME, "ordinary", "fingerprint", None, &memory)
|
||||
.unwrap();
|
||||
let mut collision = seed(&request);
|
||||
collision.worker_id = ordinary.worker_id;
|
||||
assert!(seed_fixed_worker(&store, collision).is_err());
|
||||
// Store-level fault setup only: the external harness never writes SQL.
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
allocate_resource_key(
|
||||
conn,
|
||||
WORKSPACE,
|
||||
WorkspaceResourceKind::Worker,
|
||||
&worker_resource_id(&worker()),
|
||||
"now",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_resource_identity_and_retained_removal_fence_are_refused() {
|
||||
let request = request();
|
||||
let malformed = bootstrap();
|
||||
malformed
|
||||
.with_conn(|conn| {
|
||||
allocate_resource_key(
|
||||
conn,
|
||||
WORKSPACE,
|
||||
WorkspaceResourceKind::Worker,
|
||||
"unknown-identity",
|
||||
"now",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
seed_fixed_worker(&malformed, seed(&request)),
|
||||
Err(Error::InvalidInput(_))
|
||||
));
|
||||
assert!(
|
||||
malformed
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let removed = bootstrap();
|
||||
removed.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO worker_registry_projection_removals(workspace_id, runtime_id, worker_id) VALUES (?1, ?2, ?3)",
|
||||
params![WORKSPACE, RUNTIME, WORKER],
|
||||
)?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(matches!(
|
||||
seed_fixed_worker(&removed, seed(&request)),
|
||||
Err(Error::InvalidInput(_))
|
||||
));
|
||||
assert!(
|
||||
removed
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_workspace_runtime_and_revoked_binding_are_refused() {
|
||||
let store = bootstrap();
|
||||
let request = request();
|
||||
let mut unknown = seed(&request);
|
||||
unknown.workspace_id = "unknown";
|
||||
assert!(seed_fixed_worker(&store, unknown).is_err());
|
||||
let mut unknown = seed(&request);
|
||||
unknown.runtime_id = "unknown";
|
||||
assert!(seed_fixed_worker(&store, unknown).is_err());
|
||||
store
|
||||
.revoke_workspace_runtime_binding(WORKSPACE, RUNTIME, "2026-10-24T01:00:00Z")
|
||||
.unwrap();
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
assert!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_intent_is_refused_without_partial_registration() {
|
||||
let store = bootstrap();
|
||||
let base = request();
|
||||
let mut requests = Vec::new();
|
||||
let mut request = base.clone();
|
||||
request.singleton_key = Some("fixture-singleton".into());
|
||||
requests.push(request);
|
||||
let mut request = base.clone();
|
||||
request.intent = WorkerSpawnIntent::WorkspaceOrchestrator;
|
||||
requests.push(request);
|
||||
let mut request = base.clone();
|
||||
request.resolved_subjektiv_attached = true;
|
||||
requests.push(request);
|
||||
let mut request = base.clone();
|
||||
request
|
||||
.workdir_attachment_requests
|
||||
.push(WorkerSpawnWorkingDirectoryAttachmentRequest {
|
||||
alias: workdir::WorkdirAttachmentAlias::new("repo").unwrap(),
|
||||
working_directory: WorkerSpawnWorkingDirectoryRequest {
|
||||
repository_key: "repo".into(),
|
||||
selector: None,
|
||||
},
|
||||
});
|
||||
requests.push(request);
|
||||
for request in requests {
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
seed_fixed_worker(&store, seed(&base))
|
||||
.unwrap()
|
||||
.worker_id
|
||||
.to_string(),
|
||||
WORKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_catalog_failure_rolls_back_reservation_and_resource_allocation() {
|
||||
let store = bootstrap();
|
||||
let request = request();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TEMP TRIGGER fail_fixture_catalog BEFORE INSERT ON worker_registry
|
||||
BEGIN SELECT RAISE(ABORT, 'injected catalog failure'); END",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
assert!(
|
||||
!store
|
||||
.has_active_worker_create_reservation(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER fail_fixture_catalog")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
seed_fixed_worker(&store, seed(&request)).unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("W-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_creation_still_allocates_and_replays_without_catalog_insertion() {
|
||||
let store = bootstrap();
|
||||
let memory = store.get_workspace_memory_settings(WORKSPACE).unwrap();
|
||||
let reserve = |key| {
|
||||
store
|
||||
.reserve_worker_create(WORKSPACE, RUNTIME, key, "fingerprint", None, &memory)
|
||||
.unwrap()
|
||||
};
|
||||
let first = reserve("ordinary-one");
|
||||
assert_eq!(first, reserve("ordinary-one"));
|
||||
let second = reserve("ordinary-two");
|
||||
assert_ne!(first.worker_id, second.worker_id);
|
||||
assert_ne!(first.worker_id.to_string(), WORKER);
|
||||
assert_eq!(
|
||||
first.worker_id.as_uuid().get_version(),
|
||||
Some(uuid::Version::SortRand)
|
||||
);
|
||||
let worker = RuntimeWorkerRef::new(RUNTIME, first.worker_id.to_string());
|
||||
assert!(
|
||||
store
|
||||
.has_active_worker_create_reservation(WORKSPACE, &worker)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry(WORKSPACE, &worker)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.reserve_worker_create(WORKSPACE, RUNTIME, "ordinary-one", "changed", None, &memory)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ autotests = false
|
||||
[features]
|
||||
default = []
|
||||
e2e = []
|
||||
fixture-support = ["yoi-workspace-server/test-support", "worker-runtime/test-support"]
|
||||
|
||||
[dependencies]
|
||||
libc.workspace = true
|
||||
@@ -17,11 +18,17 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
protocol.workspace = true
|
||||
tungstenite = { version = "0.29", default-features = false, features = ["handshake"] }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
yoi-workspace-server.workspace = true
|
||||
server-api.workspace = true
|
||||
worker-runtime.workspace = true
|
||||
session-store.workspace = true
|
||||
config-source.workspace = true
|
||||
manifest.workspace = true
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
||||
|
||||
@@ -44,3 +51,8 @@ required-features = ["e2e"]
|
||||
name = "worker_environment"
|
||||
path = "tests/worker_environment.rs"
|
||||
required-features = ["e2e"]
|
||||
|
||||
[[test]]
|
||||
name = "worker_restore"
|
||||
path = "tests/worker_restore.rs"
|
||||
required-features = ["e2e", "fixture-support"]
|
||||
|
||||
@@ -7,3 +7,41 @@
|
||||
- [`PanelHarness`]: PTY経由の入力、観測イベントによる同期、失敗時のartifact保存。
|
||||
- [`FixtureWorkspace`]: TUI用WorkspaceとHOME/XDG領域の隔離。
|
||||
- [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。
|
||||
- [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。
|
||||
- [`WorkerSessionSubscription`]: 公開Worker WebSocketの初期full Snapshot照合と停止確認。モデル呼出しは行わない。
|
||||
|
||||
## 固定Workerの段階起動
|
||||
|
||||
1. 提供fixtureを所有crateの型で検証し、`FixtureSource`で原本hashを記録する。各環境の一時領域へcopyする。fixtureが未提供・不適合なら失敗とし、代替Workerを生成しない。
|
||||
2. `initialize_runtime_identity`で固定Runtime IDに新しい鍵を発行する。Backendの型付きstore APIで新しいAccount認証・Workspace署名identity・Runtime binding・固定Worker登録を準備する。
|
||||
3. `start_seeded_backend`へ固定Workspace/Runtime IDとその環境の新しいbearerを渡す。Server起動後の公開APIからWorkspaceの公開identityを取得する。
|
||||
4. `start_runtime_with_seed`はtrust設定後、Runtime processの起動前にseed closureを呼ぶ。ここで実Server URLを使いRuntime所有crateの型付きinstallを呼ぶ。Runtime store rootは`runtime_data_dir.join("runtime")`。
|
||||
5. Worker一覧とdetailから停止状態・観測tokenを確認し、一つの固定request IDで`restore_worker`を一度だけ呼ぶ。結果不明・拒否は成功扱いせず、tokenを自動更新しない。
|
||||
6. `WorkerSessionSubscription::connect`で期待`protocol::SessionSnapshot`全体(entry ID、timestamp、順序、本文を含む)を照合する。二環境を同時に保持し、一方のstop/cleanup後も他方の購読・一覧・内容を再確認する。
|
||||
7. `stop_worker`、購読の`expect_stopped`、環境cleanup、原本`assert_unchanged`を確認する。
|
||||
|
||||
所有crateの`test-support`依存はテスト側の非default `fixture-support` featureだけで有効にする。製品binary providerは別Cargo invocationと専用`target/e2e-products`へ`yoi`・`yoi-server`・`yoi-runtime`を追加featureなしでビルドする。support test自身をこのtargetへビルドしてはならない。`YOI_E2E_BIN`等の明示binary overrideを使用する場合も、呼出し側が同じ分離を保証する。既存TUI用`e2e-test` featureは別の`target/e2e-tui-products`へビルドし、restore製品binaryへ混入しない。
|
||||
|
||||
固定資産の正常restore・並行隔離E2E:
|
||||
|
||||
```sh
|
||||
cargo test -p yoi-e2e --features e2e,fixture-support --test worker_restore
|
||||
# 親から提供された候補の検証(読取専用、原本の自動採用や生成はしない)
|
||||
YOI_E2E_RESTORE_FIXTURE=target/fixture-candidate-v1 \
|
||||
cargo test -p yoi-e2e --features e2e,fixture-support --test worker_restore
|
||||
```
|
||||
|
||||
標準資産は`tests/e2e/fixtures/worker-codex-v1`の`manifest.json`・`worker-template.json`・`expected.json`・`workspace-config.dcdl`。manifestの各asset hash/size、schemaと固定ID、source revision、modelを検証する。Workspace configのimport参照を黙って最新へ置換せず、Gitでsource revisionに対する`resources`と`crates/builtin-source`の一致を確認する(Git履歴がない場合も失敗)。移植したSessionの正規公開projectionを供給された会話行と照合し、その全snapshotを実executionのWebSocketと比較する。欠落fixture・hash不一致・未実行は成功扱いしない。
|
||||
|
||||
通常のharness回帰試験(製品processは起動しない):
|
||||
|
||||
```sh
|
||||
cargo test -p yoi-e2e --lib --no-default-features
|
||||
```
|
||||
|
||||
既存の製品process E2Eは別結果として実行・報告する:
|
||||
|
||||
```sh
|
||||
cargo test -p yoi-e2e --features e2e --test worker_environment
|
||||
cargo test -p yoi-e2e --features e2e --test panel --test rewind
|
||||
```
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"source_revision": "1639be98411921308238b3a6a6e75c784c045156",
|
||||
"model": "codex-oauth/gpt-6.1-sol",
|
||||
"worker_id": "01a1657d-b762-7791-87ae-a49f28cadbce",
|
||||
"session_id": "01a1657d-b818-77c1-bd73-175a27562f63",
|
||||
"rows": [
|
||||
{
|
||||
"entry_id": "01a1657f-18bf-7a03-b8b8-953d27041fe0",
|
||||
"timestamp": 1792704190655,
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"content": "これは保存と復元を確認するための人工的な会話です。合言葉は「青い灯台」です。覚えたことを一文で返してください。Toolは使わないでください。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"entry_id": "01a1657f-1f55-71d3-b418-8199b863f59a",
|
||||
"timestamp": 1792704192341,
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "合言葉は「青い灯台」と覚えました。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"entry_id": "01a1657f-3053-71f3-bf4f-91290d82488b",
|
||||
"timestamp": 1792704196691,
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"content": "先ほどの合言葉は何でしたか?合言葉だけを答えてください。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"entry_id": "01a1657f-36ef-78b1-b0c8-b141f13b0c7b",
|
||||
"timestamp": 1792704198383,
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "青い灯台"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"source_revision": "1639be98411921308238b3a6a6e75c784c045156",
|
||||
"runtime_schema_version": 12,
|
||||
"session_schema_version": 6,
|
||||
"workspace_id": "01a16553-b901-7191-8ceb-8e2306fe3da9",
|
||||
"runtime_id": "fixture-951707ee-93ca-4205-9c12-45f08a104a74",
|
||||
"worker_id": "01a1657d-b762-7791-87ae-a49f28cadbce",
|
||||
"session_id": "01a1657d-b818-77c1-bd73-175a27562f63",
|
||||
"segment_id": "01a1657d-b818-77c1-bd73-176172a4bd82",
|
||||
"model": "codex-oauth/gpt-6.1-sol",
|
||||
"state": "stopped",
|
||||
"acquisition": "Real isolated Codex OAuth two-turn artificial conversation; typed offline StoppedWorkerTemplate extraction. Session bytes unchanged; environment credentials and execution receipts excluded.",
|
||||
"source_file_sha256": {
|
||||
"worker.json": "912e620013a17f05c1aaf900da48c9ee0ecb1f54121efc3721e03f8755eb5eca",
|
||||
"metadata.json": "657928a89ec621bd092b86924201ed7b562e9683261789ce18bdb1d248bdc450",
|
||||
"session/session.json": "22b01daab68a28b9777a1f7abb9ef9f7f6cbae113089446602f5d15421e7c7f3",
|
||||
"session/segments/01a1657d-b818-77c1-bd73-176172a4bd82.jsonl": "7c5663bb06d9406d5bd0a5086159b31c0d7799458fcb1c6f81e18b63c49f4df5"
|
||||
},
|
||||
"files": {
|
||||
"worker-template.json": {
|
||||
"bytes": 48343,
|
||||
"sha256": "8bf6ce3c71c4f05c82314de93c1e8e51b7502b00915b6adb8b33046944e463b2"
|
||||
},
|
||||
"expected.json": {
|
||||
"bytes": 1474,
|
||||
"sha256": "b9846db47ecb8f3f53b2abd9d5f0c766122f86015a7a7df7addf8beab3e9c55a"
|
||||
},
|
||||
"workspace-config.dcdl": {
|
||||
"bytes": 1141,
|
||||
"sha256": "9b07507b71733081f077e2e9968a7f1f676c916a243bb355d1813f067a55c415"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
profile = {
|
||||
default_profile = "project:fixture-chat";
|
||||
entries = [{
|
||||
selector = "project:fixture-chat";
|
||||
label = "Isolated Codex fixture conversation";
|
||||
description = "Artificial conversation only; no Workdir, Tools, Subject or background work.";
|
||||
profile = (import "$builtin/profiles/job.dcdl") // {
|
||||
slug = "fixture-chat";
|
||||
worker = { mode = "tools"; };
|
||||
model = { ref = "codex-oauth/gpt-6.1-sol"; };
|
||||
scope = "workspace_read";
|
||||
delegation_scope = "workspace_read";
|
||||
permissions = { default_action = "deny"; rule = []; };
|
||||
engine = { instruction = "default"; reasoning = "low"; max_tokens = 2048; max_turns = 4; language = "Japanese"; };
|
||||
session = { record_event_trace = false; };
|
||||
feature = { workdir_catalog = { enabled = false; }; };
|
||||
};
|
||||
}];
|
||||
};
|
||||
prompts = { default_prompt = "これは隔離環境の人工的な会話テストです。日本語で簡潔に応答してください。Toolや外部操作は行わず、ユーザーが会話中に提示した情報だけを使ってください。"; };
|
||||
} as WorkspaceConfigSchema
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Byte-preserving fixture transport, independent of the owning store's schema validation.
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{HarnessError, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FixtureSource {
|
||||
root: PathBuf,
|
||||
hashes: BTreeMap<PathBuf, String>,
|
||||
}
|
||||
|
||||
impl FixtureSource {
|
||||
/// Record every supplied file, including the manifest. No fixture is generated
|
||||
/// or repaired here; the typed store importer must additionally validate it.
|
||||
pub fn capture(root: &Path) -> Result<Self> {
|
||||
let hashes = inventory(root)?;
|
||||
if hashes.is_empty() {
|
||||
return Err(HarnessError::Protocol("provided fixture is empty".into()));
|
||||
}
|
||||
Ok(Self {
|
||||
root: root.to_owned(),
|
||||
hashes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hashes(&self) -> &BTreeMap<PathBuf, String> {
|
||||
&self.hashes
|
||||
}
|
||||
|
||||
pub fn assert_unchanged(&self) -> Result<()> {
|
||||
if inventory(&self.root)? != self.hashes {
|
||||
return Err(HarnessError::Protocol(
|
||||
"provided fixture changed during execution".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The destination must not exist. A copy is never a link to the source.
|
||||
pub fn copy_to(&self, destination: &Path) -> Result<()> {
|
||||
self.assert_unchanged()?;
|
||||
let parent = destination
|
||||
.parent()
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
if parent
|
||||
.canonicalize()?
|
||||
.starts_with(self.root.canonicalize()?)
|
||||
{
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture copy destination is inside the original".into(),
|
||||
));
|
||||
}
|
||||
fs::create_dir(destination)?;
|
||||
for (relative, expected) in &self.hashes {
|
||||
let bytes = fs::read(self.root.join(relative))?;
|
||||
if hash(&bytes) != *expected {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture changed while copying".into(),
|
||||
));
|
||||
}
|
||||
let target = destination.join(relative);
|
||||
fs::create_dir_all(target.parent().unwrap())?;
|
||||
use std::io::Write;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(target)?;
|
||||
file.write_all(&bytes)?;
|
||||
}
|
||||
if inventory(destination)? != self.hashes {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture copy failed hash verification".into(),
|
||||
));
|
||||
}
|
||||
self.assert_unchanged()
|
||||
}
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
format!(
|
||||
"sha256:{}",
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
)
|
||||
}
|
||||
|
||||
fn inventory(root: &Path) -> Result<BTreeMap<PathBuf, String>> {
|
||||
fn visit(root: &Path, path: &Path, files: &mut BTreeMap<PathBuf, String>) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if metadata.is_dir() {
|
||||
for entry in fs::read_dir(path)? {
|
||||
visit(root, &entry?.path(), files)?;
|
||||
}
|
||||
} else if metadata.is_file() {
|
||||
files.insert(
|
||||
path.strip_prefix(root).unwrap().to_owned(),
|
||||
hash(&fs::read(path)?),
|
||||
);
|
||||
} else {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture contains a symlink or special file".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
if !fs::symlink_metadata(root)?.is_dir() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture source must be a real directory".into(),
|
||||
));
|
||||
}
|
||||
let mut files = BTreeMap::new();
|
||||
visit(root, root, &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// These are transport-only byte samples, never saved Worker substitutes.
|
||||
#[test]
|
||||
fn copies_are_independent_and_source_mutation_is_detected() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let source = temp.path().join("source");
|
||||
fs::create_dir(&source)?;
|
||||
fs::write(source.join("bytes"), b"original")?;
|
||||
let fixture = FixtureSource::capture(&source)?;
|
||||
assert!(fixture.copy_to(&source.join("nested-copy")).is_err());
|
||||
assert!(!source.join("nested-copy").exists());
|
||||
fixture.assert_unchanged()?;
|
||||
let first = temp.path().join("first");
|
||||
let second = temp.path().join("second");
|
||||
fixture.copy_to(&first)?;
|
||||
fixture.copy_to(&second)?;
|
||||
fs::write(first.join("bytes"), b"changed")?;
|
||||
fs::remove_dir_all(first)?;
|
||||
assert_eq!(fs::read(second.join("bytes"))?, b"original");
|
||||
fixture.assert_unchanged()?;
|
||||
fs::write(source.join("bytes"), b"changed")?;
|
||||
assert!(fixture.assert_unchanged().is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_empty_existing_destination_and_extra_file() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
assert!(FixtureSource::capture(&temp.path().join("missing")).is_err());
|
||||
assert!(FixtureSource::capture(temp.path()).is_err());
|
||||
fs::write(temp.path().join("bytes"), b"original")?;
|
||||
let source = FixtureSource::capture(temp.path())?;
|
||||
assert!(source.copy_to(temp.path()).is_err());
|
||||
fs::write(temp.path().join("extra"), b"new")?;
|
||||
assert!(source.assert_unchanged().is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symbolic_links() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
fs::write(temp.path().join("bytes"), b"original")?;
|
||||
std::os::unix::fs::symlink("bytes", temp.path().join("link"))?;
|
||||
assert!(FixtureSource::capture(temp.path()).is_err());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -1268,7 +1268,11 @@ impl Drop for PanelHarness {
|
||||
}
|
||||
}
|
||||
|
||||
mod fixture_source;
|
||||
pub use fixture_source::FixtureSource;
|
||||
mod worker_environment;
|
||||
mod worker_session;
|
||||
pub use worker_session::{WorkerSessionSubscription, validate_full_snapshot};
|
||||
|
||||
pub use worker_environment::{
|
||||
WorkerBinaryProviderInfo, WorkerE2eEnvironment, WorkerEnvironmentCleanupReport,
|
||||
@@ -1627,6 +1631,8 @@ fn resolve_yoi_binary() -> Result<BinaryProviderInfo> {
|
||||
"e2e-test".to_owned(),
|
||||
"--bin".to_owned(),
|
||||
"yoi".to_owned(),
|
||||
"--target-dir".to_owned(),
|
||||
tui_product_target_dir()?.to_string_lossy().into_owned(),
|
||||
];
|
||||
if test_profile() == "release" {
|
||||
args.push("--release".to_owned());
|
||||
@@ -1648,7 +1654,9 @@ fn resolve_yoi_binary() -> Result<BinaryProviderInfo> {
|
||||
});
|
||||
}
|
||||
|
||||
let binary = current_target_profile_dir()?.join(binary_name());
|
||||
let binary = tui_product_target_dir()?
|
||||
.join(test_profile())
|
||||
.join(binary_name());
|
||||
let info = BinaryProviderInfo {
|
||||
provider: "cargo-build".to_owned(),
|
||||
binary,
|
||||
@@ -1675,6 +1683,26 @@ fn workspace_root() -> Result<PathBuf> {
|
||||
.ok_or_else(|| HarnessError::Protocol("could not resolve workspace root".to_owned()))
|
||||
}
|
||||
|
||||
/// Product processes must never reuse the target used by test-support builds.
|
||||
fn product_target_dir() -> Result<PathBuf> {
|
||||
dedicated_product_target("e2e-products")
|
||||
}
|
||||
|
||||
// Existing PTY tests need their observer feature, never on restore binaries.
|
||||
fn tui_product_target_dir() -> Result<PathBuf> {
|
||||
dedicated_product_target("e2e-tui-products")
|
||||
}
|
||||
|
||||
fn dedicated_product_target(name: &str) -> Result<PathBuf> {
|
||||
let target = workspace_root()?.join("target").join(name);
|
||||
if current_target_profile_dir()?.parent() == Some(target.as_path()) {
|
||||
return Err(HarnessError::Protocol(
|
||||
"run support tests outside reserved product binary targets".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn current_target_profile_dir() -> Result<PathBuf> {
|
||||
let mut path = std::env::current_exe()?;
|
||||
while let Some(name) = path.file_name().and_then(|name| name.to_str()) {
|
||||
|
||||
@@ -13,8 +13,8 @@ use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{
|
||||
FixtureAllocation, HarnessError, Result, allocate_fixture, command_display,
|
||||
current_target_profile_dir, now_ms, test_profile, workspace_root, yoi_binary_info,
|
||||
FixtureAllocation, HarnessError, Result, allocate_fixture, command_display, now_ms,
|
||||
product_target_dir, test_profile, workspace_root,
|
||||
};
|
||||
|
||||
const STARTUP_WAIT: Duration = Duration::from_secs(15);
|
||||
@@ -61,22 +61,20 @@ pub fn worker_binary_provider_info() -> Result<WorkerBinaryProviderInfo> {
|
||||
fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> {
|
||||
let workspace_root = workspace_root()?;
|
||||
let profile = test_profile();
|
||||
let yoi = yoi_binary_info()?;
|
||||
let mut records = vec![binary_record(
|
||||
"yoi",
|
||||
yoi.binary,
|
||||
yoi.provider,
|
||||
yoi.build_command,
|
||||
)?];
|
||||
|
||||
let mut records = Vec::new();
|
||||
let yoi_override = resolve_override("YOI_E2E_BIN", &workspace_root)?;
|
||||
let server_override = resolve_override("YOI_E2E_SERVER_BIN", &workspace_root)?;
|
||||
let runtime_override = resolve_override("YOI_E2E_RUNTIME_BIN", &workspace_root)?;
|
||||
let mut build_command = None;
|
||||
if server_override.is_none() || runtime_override.is_none() {
|
||||
if yoi_override.is_none() || server_override.is_none() || runtime_override.is_none() {
|
||||
let cargo = PathBuf::from(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
|
||||
let mut args = vec![
|
||||
"build".to_string(),
|
||||
"-p".to_string(),
|
||||
"yoi".to_string(),
|
||||
"--bin".to_string(),
|
||||
"yoi".to_string(),
|
||||
"-p".to_string(),
|
||||
"yoi-workspace-server".to_string(),
|
||||
"--bin".to_string(),
|
||||
"yoi-server".to_string(),
|
||||
@@ -84,6 +82,8 @@ fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> {
|
||||
"worker-runtime".to_string(),
|
||||
"--bin".to_string(),
|
||||
"yoi-runtime".to_string(),
|
||||
"--target-dir".to_string(),
|
||||
product_target_dir()?.to_string_lossy().into_owned(),
|
||||
];
|
||||
if profile == "release" {
|
||||
args.push("--release".to_string());
|
||||
@@ -106,7 +106,16 @@ fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> {
|
||||
build_command = Some(rendered);
|
||||
}
|
||||
|
||||
let profile_dir = current_target_profile_dir()?;
|
||||
let profile_dir = product_target_dir()?.join(&profile);
|
||||
let (yoi_path, yoi_provider, yoi_build) = match yoi_override {
|
||||
Some(path) => (path, "YOI_E2E_BIN".to_string(), None),
|
||||
None => (
|
||||
profile_dir.join(executable_name("yoi")),
|
||||
"cargo-build".to_string(),
|
||||
build_command.clone(),
|
||||
),
|
||||
};
|
||||
records.push(binary_record("yoi", yoi_path, yoi_provider, yoi_build)?);
|
||||
let (server_path, server_provider, server_build) = match server_override {
|
||||
Some(path) => (path, "YOI_E2E_SERVER_BIN".to_string(), None),
|
||||
None => (
|
||||
@@ -231,6 +240,7 @@ pub struct WorkerE2eEnvironment {
|
||||
pub runtime_url: Option<String>,
|
||||
server: Option<ManagedProcess>,
|
||||
runtime: Option<ManagedProcess>,
|
||||
runtime_public_identity: Option<Value>,
|
||||
access_token: Option<String>,
|
||||
client: Client,
|
||||
binaries: WorkerBinaryProviderInfo,
|
||||
@@ -292,6 +302,7 @@ impl WorkerE2eEnvironment {
|
||||
runtime_url: None,
|
||||
server: None,
|
||||
runtime: None,
|
||||
runtime_public_identity: None,
|
||||
access_token: None,
|
||||
client,
|
||||
binaries,
|
||||
@@ -374,6 +385,47 @@ impl WorkerE2eEnvironment {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a store populated by the Backend's typed test-support API. The seed
|
||||
/// supplies fixed catalog IDs, but the bearer must be freshly issued for this
|
||||
/// environment. No database mutation or fixture credential import occurs here.
|
||||
pub fn start_seeded_backend(
|
||||
&mut self,
|
||||
workspace_id: String,
|
||||
runtime_id: String,
|
||||
access_token: String,
|
||||
) -> Result<()> {
|
||||
if self.server.is_some() || self.runtime.is_some() || self.workspace_id.is_some() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture seed requires an unstarted environment".into(),
|
||||
));
|
||||
}
|
||||
if workspace_id.is_empty() || runtime_id.is_empty() || access_token.is_empty() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture seed omitted an identity or fresh bearer".into(),
|
||||
));
|
||||
}
|
||||
if !self.server_data_dir.join("server.db").is_file() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"typed Backend seed has not created server.db".into(),
|
||||
));
|
||||
}
|
||||
if self.runtime_public_identity.is_some() {
|
||||
if self.runtime_id != runtime_id {
|
||||
return Err(HarnessError::Protocol(
|
||||
"seeded Runtime ID differs from initialized identity".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
|
||||
}
|
||||
self.workspace_id = Some(workspace_id);
|
||||
self.runtime_id = runtime_id;
|
||||
self.access_token = Some(access_token);
|
||||
self.start_server_process()?;
|
||||
self.export_workspace_public_identity()?;
|
||||
self.write_metadata("seeded-backend-running")
|
||||
}
|
||||
|
||||
pub fn start_backend(&mut self) -> Result<()> {
|
||||
self.start_empty_backend()?;
|
||||
let server_binary = self.binaries.binary("yoi-server")?;
|
||||
@@ -417,7 +469,15 @@ impl WorkerE2eEnvironment {
|
||||
"create Workspace",
|
||||
)?;
|
||||
let workspace_id = json_string(&created, "/workspace/workspace_id")?;
|
||||
self.workspace_id = Some(workspace_id.clone());
|
||||
self.workspace_id = Some(workspace_id);
|
||||
self.export_workspace_public_identity()?;
|
||||
self.write_metadata("backend-running")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export_workspace_public_identity(&self) -> Result<()> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let workspace_id = self.required_workspace_id()?;
|
||||
let identity = self.request_json(
|
||||
self.client.get(format!(
|
||||
"{server_url}/api/w/{workspace_id}/settings/signing-identity"
|
||||
@@ -429,17 +489,27 @@ impl WorkerE2eEnvironment {
|
||||
})?;
|
||||
let bundle_path = self.root.join("workspace-public-identity.json");
|
||||
fs::write(&bundle_path, serde_json::to_vec_pretty(&public_bundle)?)?;
|
||||
self.write_metadata("backend-running")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_runtime(&mut self) -> Result<()> {
|
||||
let bundle_path = self.root.join("workspace-public-identity.json");
|
||||
let server_url = self.required_server_url()?.to_string();
|
||||
let workspace_id = self.required_workspace_id()?.to_string();
|
||||
let runtime_binary = self.binaries.binary("yoi-runtime")?;
|
||||
self.start_runtime_with_seed(|_| Ok(()))?;
|
||||
self.assert_connected_and_empty()
|
||||
}
|
||||
|
||||
/// Generate fresh keys before offline Backend registration when fixed IDs are
|
||||
/// required. Returns only the public bundle; no fixture keys are accepted.
|
||||
pub fn initialize_runtime_identity(&mut self, runtime_id: String) -> Result<Value> {
|
||||
if self.runtime_public_identity.is_some() || self.runtime.is_some() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"Runtime identity is already initialized".into(),
|
||||
));
|
||||
}
|
||||
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
|
||||
self.runtime_id = runtime_id;
|
||||
let binary = self.binaries.binary("yoi-runtime")?;
|
||||
self.run_command(
|
||||
&runtime_binary,
|
||||
&binary,
|
||||
&[
|
||||
"identity",
|
||||
"init",
|
||||
@@ -450,8 +520,8 @@ impl WorkerE2eEnvironment {
|
||||
],
|
||||
false,
|
||||
)?;
|
||||
let runtime_identity = self.run_command(
|
||||
&runtime_binary,
|
||||
let identity = self.run_command(
|
||||
&binary,
|
||||
&[
|
||||
"identity",
|
||||
"show",
|
||||
@@ -461,7 +531,31 @@ impl WorkerE2eEnvironment {
|
||||
],
|
||||
false,
|
||||
)?;
|
||||
let runtime_identity: Value = serde_json::from_slice(&runtime_identity.stdout)?;
|
||||
let identity: Value = serde_json::from_slice(&identity.stdout)?;
|
||||
self.runtime_public_identity = Some(identity.clone());
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Initialize fresh Runtime keys/trust, then invoke the typed Runtime seed
|
||||
/// before starting its process. The actual Backend URL is now available.
|
||||
pub fn start_runtime_with_seed(
|
||||
&mut self,
|
||||
seed: impl FnOnce(&Self) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
if self.runtime.is_some() {
|
||||
return Err(HarnessError::Protocol("Runtime is already running".into()));
|
||||
}
|
||||
if self.runtime_public_identity.is_none() {
|
||||
self.initialize_runtime_identity(self.runtime_id.clone())?;
|
||||
}
|
||||
let runtime_identity = self
|
||||
.runtime_public_identity
|
||||
.clone()
|
||||
.expect("initialized identity");
|
||||
let bundle_path = self.root.join("workspace-public-identity.json");
|
||||
let server_url = self.required_server_url()?.to_string();
|
||||
let workspace_id = self.required_workspace_id()?.to_string();
|
||||
let runtime_binary = self.binaries.binary("yoi-runtime")?;
|
||||
self.run_command(
|
||||
&runtime_binary,
|
||||
&[
|
||||
@@ -497,6 +591,8 @@ impl WorkerE2eEnvironment {
|
||||
));
|
||||
}
|
||||
|
||||
seed(self)?;
|
||||
self.write_metadata("runtime-seeded")?;
|
||||
let mut command = self.isolated_command(&runtime_binary);
|
||||
command.args([
|
||||
"--bind",
|
||||
@@ -519,22 +615,38 @@ impl WorkerE2eEnvironment {
|
||||
self.runtime = Some(runtime);
|
||||
self.runtime_url = Some(runtime_url.clone());
|
||||
|
||||
let registered = self.request_json(
|
||||
self.client
|
||||
.get(format!("{server_url}/api/w/{workspace_id}/runtimes")),
|
||||
"observe Runtime registration before endpoint update",
|
||||
)?;
|
||||
let expected_binding_id = registered
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("runtime_id").and_then(Value::as_str) == Some(self.runtime_id.as_str())
|
||||
})
|
||||
})
|
||||
.and_then(|item| item.pointer("/management/binding/binding_id"))
|
||||
.cloned();
|
||||
self.request_json(
|
||||
self.client
|
||||
.post(format!("{server_url}/api/w/{workspace_id}/runtimes"))
|
||||
.json(&serde_json::json!({
|
||||
"public_bundle": runtime_identity,
|
||||
"display_name": format!("E2E {}", self.runtime_id),
|
||||
"endpoint": runtime_url
|
||||
"endpoint": runtime_url,
|
||||
"expected_binding_id": expected_binding_id,
|
||||
})),
|
||||
"register Runtime",
|
||||
)?;
|
||||
self.assert_connected_and_empty()?;
|
||||
self.assert_connected()?;
|
||||
self.write_metadata("running")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn assert_connected_and_empty(&self) -> Result<()> {
|
||||
pub fn assert_connected(&self) -> Result<()> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let workspace_id = self.required_workspace_id()?;
|
||||
// connection testは実署名要求。保存済みの接続flagを認証成功の代わりにしない。
|
||||
@@ -574,11 +686,22 @@ impl WorkerE2eEnvironment {
|
||||
self.runtime_id
|
||||
)));
|
||||
}
|
||||
let workers = self.request_json(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_workers(&self) -> Result<Value> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let workspace_id = self.required_workspace_id()?;
|
||||
self.request_json(
|
||||
self.client
|
||||
.get(format!("{server_url}/api/w/{workspace_id}/workers")),
|
||||
"list Workers",
|
||||
)?;
|
||||
)
|
||||
}
|
||||
|
||||
pub fn assert_connected_and_empty(&self) -> Result<()> {
|
||||
self.assert_connected()?;
|
||||
let workers = self.list_workers()?;
|
||||
if workers
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
@@ -591,6 +714,104 @@ impl WorkerE2eEnvironment {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn worker_url(&self, worker_id: &str, suffix: &str) -> Result<String> {
|
||||
let mut url = reqwest::Url::parse(self.required_server_url()?)
|
||||
.map_err(|error| HarnessError::Protocol(format!("invalid Server URL: {error}")))?;
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| HarnessError::Protocol("Server URL has no path segments".into()))?
|
||||
.extend([
|
||||
"api",
|
||||
"w",
|
||||
self.required_workspace_id()?,
|
||||
"runtimes",
|
||||
&self.runtime_id,
|
||||
"workers",
|
||||
worker_id,
|
||||
]);
|
||||
if !suffix.is_empty() {
|
||||
url.path_segments_mut().unwrap().extend(suffix.split('/'));
|
||||
}
|
||||
Ok(url.into())
|
||||
}
|
||||
|
||||
pub fn worker_detail(&self, worker_id: &str) -> Result<Value> {
|
||||
self.request_json(
|
||||
self.client.get(self.worker_url(worker_id, "")?),
|
||||
"read Worker detail",
|
||||
)
|
||||
}
|
||||
|
||||
/// Exactly one restore submission. No retries, replacement token or new intent.
|
||||
pub fn restore_worker(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
request_id: &str,
|
||||
expected_observation_token: &str,
|
||||
) -> Result<Value> {
|
||||
self.request_json(
|
||||
self.client
|
||||
.post(self.worker_url(worker_id, "restore")?)
|
||||
.timeout(STARTUP_WAIT)
|
||||
.json(&serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"expected_observation_token": expected_observation_token,
|
||||
})),
|
||||
"restore Worker",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stop_worker(&self, worker_id: &str) -> Result<Value> {
|
||||
self.request_json(
|
||||
self.client
|
||||
.post(self.worker_url(worker_id, "stop")?)
|
||||
.json(&serde_json::json!({})),
|
||||
"stop Worker",
|
||||
)
|
||||
}
|
||||
|
||||
/// Authorization stays in memory; callers must not persist or debug this request.
|
||||
pub fn worker_protocol_request(&self, worker_id: &str) -> Result<reqwest::blocking::Request> {
|
||||
let token = self.access_token.as_deref().ok_or_else(|| {
|
||||
HarnessError::Protocol("Backend access token is not initialized".into())
|
||||
})?;
|
||||
self.client
|
||||
.get(self.worker_url(worker_id, "protocol/ws")?)
|
||||
.bearer_auth(token)
|
||||
.build()
|
||||
.map_err(|_| HarnessError::Protocol("build Worker protocol request".into()))
|
||||
}
|
||||
|
||||
/// The other environment must reject this environment's fresh bearer even
|
||||
/// when both contain identical Workspace/Runtime/Worker IDs. Never logs tokens.
|
||||
pub fn assert_authentication_isolated_from(&self, other: &Self) -> Result<()> {
|
||||
let token = self
|
||||
.access_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| HarnessError::Protocol("missing source bearer".into()))?;
|
||||
if other.access_token.as_deref() == Some(token) {
|
||||
return Err(HarnessError::Protocol(
|
||||
"environments share authentication material".into(),
|
||||
));
|
||||
}
|
||||
let (server, workspace) = other.backend_target()?;
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{server}/api/w/{workspace}/workers"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.map_err(|_| {
|
||||
HarnessError::Protocol("cross-environment authorization request failed".into())
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
other.append_operation("reject other environment bearer", status)?;
|
||||
if status != 401 {
|
||||
return Err(HarnessError::Protocol(format!(
|
||||
"cross-environment bearer was not rejected with 401: {status}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn install_backend_token(&self, config_home: &Path) -> Result<PathBuf> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let access_token = self.access_token.as_deref().ok_or_else(|| {
|
||||
@@ -636,7 +857,7 @@ impl WorkerE2eEnvironment {
|
||||
"assignee": null,
|
||||
"readiness": null,
|
||||
"risk_flags": [],
|
||||
"workflow_state": "planning",
|
||||
"workflow_state": "open",
|
||||
"queued_by": null,
|
||||
"queued_at": null,
|
||||
"repository_key": null,
|
||||
@@ -1026,7 +1247,7 @@ impl ManagedProcess {
|
||||
fn shutdown(&mut self, timeout: Duration) -> Result<ExitStatus> {
|
||||
if let Some(status) = self.child.try_wait()? {
|
||||
self.join_readers();
|
||||
return Ok(status);
|
||||
return self.require_successful_exit(status);
|
||||
}
|
||||
let signal_result = unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) };
|
||||
if signal_result != 0 {
|
||||
@@ -1036,13 +1257,7 @@ impl ManagedProcess {
|
||||
loop {
|
||||
if let Some(status) = self.child.try_wait()? {
|
||||
self.join_readers();
|
||||
if status.success() {
|
||||
return Ok(status);
|
||||
}
|
||||
return Err(HarnessError::Protocol(format!(
|
||||
"{} did not complete graceful shutdown: {status}",
|
||||
self.name
|
||||
)));
|
||||
return self.require_successful_exit(status);
|
||||
}
|
||||
if start.elapsed() >= timeout {
|
||||
let _ = self.child.kill();
|
||||
@@ -1057,6 +1272,17 @@ impl ManagedProcess {
|
||||
}
|
||||
}
|
||||
|
||||
fn require_successful_exit(&self, status: ExitStatus) -> Result<ExitStatus> {
|
||||
if status.success() {
|
||||
Ok(status)
|
||||
} else {
|
||||
Err(HarnessError::Protocol(format!(
|
||||
"{} did not complete graceful shutdown: {status}",
|
||||
self.name
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn join_readers(&mut self) {
|
||||
for reader in self.readers.drain(..) {
|
||||
let _ = reader.join();
|
||||
@@ -1177,6 +1403,34 @@ fn io_error(operation: &str, error: std::io::Error) -> HarnessError {
|
||||
mod startup_address_tests {
|
||||
use super::parse_address_after;
|
||||
|
||||
#[test]
|
||||
fn shutdown_rejects_nonzero_exit_even_when_process_already_exited() -> super::Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
for code in [0, 7] {
|
||||
let mut command = std::process::Command::new("/bin/sh");
|
||||
command
|
||||
.env_clear()
|
||||
.current_dir(root.path())
|
||||
.args(["-c", &format!("exit {code}")]);
|
||||
let mut process = super::ManagedProcess::spawn(
|
||||
"already-exited",
|
||||
command,
|
||||
&root.path().join(format!("{code}.stdout")),
|
||||
&root.path().join(format!("{code}.stderr")),
|
||||
)?;
|
||||
// Reap first to deterministically exercise the initial try_wait branch.
|
||||
assert_eq!(process.child.wait()?.code(), Some(code));
|
||||
let result = process.shutdown(std::time::Duration::from_secs(1));
|
||||
if code == 0 {
|
||||
assert!(result?.success());
|
||||
} else {
|
||||
assert!(matches!(result, Err(super::HarnessError::Protocol(message))
|
||||
if message.contains("did not complete graceful shutdown")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_address_waits_for_complete_valid_socket_record() {
|
||||
let record = "yoi-server listening on http://127.0.0.1:49123\n";
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Live public Worker protocol assertions. No Submit/Resume/model request is sent.
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use protocol::{Event, SessionSnapshot, WorkerState};
|
||||
use tungstenite::{Message, WebSocket, client::IntoClientRequest};
|
||||
|
||||
use crate::{HarnessError, Result, WorkerE2eEnvironment};
|
||||
|
||||
const WS_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub struct WorkerSessionSubscription {
|
||||
socket: WebSocket<TcpStream>,
|
||||
}
|
||||
|
||||
impl WorkerSessionSubscription {
|
||||
pub fn connect(
|
||||
environment: &WorkerE2eEnvironment,
|
||||
worker_id: &str,
|
||||
expected: &SessionSnapshot,
|
||||
) -> Result<Self> {
|
||||
let authorized = environment.worker_protocol_request(worker_id)?;
|
||||
let mut url = authorized.url().clone();
|
||||
url.set_scheme("ws")
|
||||
.map_err(|_| error("invalid protocol URL"))?;
|
||||
let mut request = url
|
||||
.as_str()
|
||||
.into_client_request()
|
||||
.map_err(|_| error("build protocol handshake"))?;
|
||||
request.headers_mut().extend(authorized.headers().clone());
|
||||
// Only this environment's loopback endpoint is permitted, never a proxy.
|
||||
let address = url
|
||||
.socket_addrs(|| None)
|
||||
.map_err(|_| error("resolve protocol address"))?
|
||||
.into_iter()
|
||||
.find(|address| address.ip().is_loopback())
|
||||
.ok_or_else(|| error("protocol address is not loopback"))?;
|
||||
let stream = TcpStream::connect_timeout(&address, WS_WAIT)?;
|
||||
stream.set_read_timeout(Some(WS_WAIT))?;
|
||||
stream.set_write_timeout(Some(WS_WAIT))?;
|
||||
let (socket, _) =
|
||||
tungstenite::client(request, stream).map_err(|_| error("protocol handshake failed"))?;
|
||||
let mut subscription = Self { socket };
|
||||
let deadline = Instant::now() + WS_WAIT;
|
||||
loop {
|
||||
match subscription.read(deadline)? {
|
||||
Message::Text(text) => {
|
||||
let raw: serde_json::Value = serde_json::from_str(&text)?;
|
||||
validate_full_snapshot(&raw, expected)?;
|
||||
// Only validated public content is retained, never request headers.
|
||||
std::fs::write(
|
||||
environment.artifacts_dir.join("restored-session.json"),
|
||||
serde_json::to_vec_pretty(&raw)?,
|
||||
)?;
|
||||
return Ok(subscription);
|
||||
}
|
||||
Message::Ping(_) => subscription
|
||||
.socket
|
||||
.flush()
|
||||
.map_err(|_| error("flush protocol Pong"))?,
|
||||
Message::Pong(_) => {}
|
||||
_ => return Err(error("protocol ended before its initial full Snapshot")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prove an already-open subscription remains usable after the other
|
||||
/// environment is stopped and cleaned up. This does not invoke the model.
|
||||
pub fn assert_alive(&mut self) -> Result<()> {
|
||||
let payload = b"restore-e2e-isolation".to_vec();
|
||||
self.socket
|
||||
.send(Message::Ping(payload.clone().into()))
|
||||
.map_err(|_| error("send protocol Ping"))?;
|
||||
let deadline = Instant::now() + WS_WAIT;
|
||||
loop {
|
||||
match self.read(deadline)? {
|
||||
Message::Pong(bytes) if bytes.as_ref() == payload.as_slice() => return Ok(()),
|
||||
Message::Ping(_) => self
|
||||
.socket
|
||||
.flush()
|
||||
.map_err(|_| error("flush protocol Pong"))?,
|
||||
Message::Pong(_) => {}
|
||||
_ => {
|
||||
return Err(error(
|
||||
"idle subscription changed or ended during isolation check",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A public stop must terminate the connected execution, not merely update
|
||||
/// a retained catalog row. Require the shutdown event or websocket close.
|
||||
pub fn expect_stopped(mut self) -> Result<()> {
|
||||
let deadline = Instant::now() + WS_WAIT;
|
||||
loop {
|
||||
match self.read(deadline)? {
|
||||
Message::Close(_) => return Ok(()),
|
||||
Message::Text(text) => match serde_json::from_str::<Event>(&text)? {
|
||||
Event::Shutdown => return Ok(()),
|
||||
Event::WorkerState { .. } => {}
|
||||
_ => return Err(error("unexpected protocol event after Worker stop")),
|
||||
},
|
||||
Message::Ping(_) => self
|
||||
.socket
|
||||
.flush()
|
||||
.map_err(|_| error("flush protocol Pong"))?,
|
||||
Message::Pong(_) => {}
|
||||
_ => return Err(error("unexpected protocol frame after Worker stop")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&mut self, deadline: Instant) -> Result<Message> {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.filter(|duration| !duration.is_zero())
|
||||
.ok_or_else(|| error("protocol deadline exceeded"))?;
|
||||
self.socket.get_mut().set_read_timeout(Some(remaining))?;
|
||||
self.socket
|
||||
.read()
|
||||
.map_err(|_| error("protocol read failed or timed out"))
|
||||
}
|
||||
}
|
||||
|
||||
fn error(message: &str) -> HarnessError {
|
||||
HarnessError::Protocol(message.into())
|
||||
}
|
||||
|
||||
pub fn validate_full_snapshot(raw: &serde_json::Value, expected: &SessionSnapshot) -> Result<()> {
|
||||
let event: Event = serde_json::from_value(raw.clone())?;
|
||||
// Product DTOs intentionally tolerate unknown fields for compatibility. This
|
||||
// fixture boundary does not: anything discarded by typed decoding must never
|
||||
// be persisted to an artifact, including fields outside the Session payload.
|
||||
if serde_json::to_value(&event)? != *raw {
|
||||
return Err(error(
|
||||
"noncanonical or unknown fields in full protocol Snapshot envelope",
|
||||
));
|
||||
}
|
||||
let Event::Snapshot {
|
||||
session,
|
||||
state,
|
||||
in_flight,
|
||||
internal_workers,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return Err(error("first protocol event is not a full Snapshot"));
|
||||
};
|
||||
if state.state != WorkerState::Idle || !in_flight.is_empty() || !internal_workers.is_empty() {
|
||||
return Err(error(
|
||||
"restored Worker is not an idle, completed standalone Session",
|
||||
));
|
||||
}
|
||||
// Typed equality covers entry order/ID/timestamp/provenance/content; raw
|
||||
// equality also refuses unknown fields that could leak private data.
|
||||
if session != *expected
|
||||
|| raw.pointer("/data/session") != Some(&serde_json::to_value(expected)?)
|
||||
{
|
||||
return Err(error(
|
||||
"full Session snapshot differs from supplied expected rows",
|
||||
));
|
||||
}
|
||||
if !session.pending_submissions.submissions.is_empty() {
|
||||
return Err(error("fixture has pending submissions"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use protocol::{
|
||||
Greeting, InFlightSnapshot, Segment, SessionEntryProvenance, SessionSnapshotEntry,
|
||||
SessionSnapshotEntryData, WorkerStateSnapshot, WorkerStatus,
|
||||
};
|
||||
|
||||
// Protocol-validation samples only, not a substitute saved Worker fixture.
|
||||
fn sample() -> (serde_json::Value, SessionSnapshot) {
|
||||
let session = SessionSnapshot {
|
||||
pending_submissions: Default::default(),
|
||||
entries: vec![SessionSnapshotEntry {
|
||||
entry_id: "E-unit-1".into(),
|
||||
timestamp: 123,
|
||||
provenance: SessionEntryProvenance::HumanInput,
|
||||
derived_from: vec![],
|
||||
use_policy: None,
|
||||
data: SessionSnapshotEntryData::UserInput {
|
||||
segments: vec![Segment::text("unit input")],
|
||||
},
|
||||
}],
|
||||
};
|
||||
let event = Event::Snapshot {
|
||||
session: session.clone(),
|
||||
state: WorkerStateSnapshot::from(WorkerStatus::Idle),
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
internal_workers: vec![],
|
||||
greeting: Greeting {
|
||||
worker_name: "unit".into(),
|
||||
cwd: ".".into(),
|
||||
provider: "unit".into(),
|
||||
model: "unit".into(),
|
||||
reasoning: None,
|
||||
scope_summary: "none".into(),
|
||||
tools: vec![],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
};
|
||||
(serde_json::to_value(event).unwrap(), session)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_snapshot_requires_exact_ids_timestamps_contents_and_no_unknown_fields() {
|
||||
let (raw, expected) = sample();
|
||||
validate_full_snapshot(&raw, &expected).unwrap();
|
||||
for (key, value) in [
|
||||
("entry_id", serde_json::json!("different")),
|
||||
("timestamp", serde_json::json!(124)),
|
||||
(
|
||||
"hidden_reasoning",
|
||||
serde_json::json!("must never be exposed"),
|
||||
),
|
||||
("segments", serde_json::json!([])),
|
||||
] {
|
||||
let mut changed = raw.clone();
|
||||
changed["data"]["session"]["entries"][0][key] = value;
|
||||
assert!(
|
||||
validate_full_snapshot(&changed, &expected).is_err(),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
let mut missing = raw.clone();
|
||||
missing["data"]["session"]["entries"] = serde_json::json!([]);
|
||||
assert!(validate_full_snapshot(&missing, &expected).is_err());
|
||||
let mut extra = raw;
|
||||
let entry = extra["data"]["session"]["entries"][0].clone();
|
||||
extra["data"]["session"]["entries"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(entry);
|
||||
assert!(validate_full_snapshot(&extra, &expected).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_snapshot_rejects_unknown_fields_outside_session_before_artifact_write() {
|
||||
let (raw, expected) = sample();
|
||||
for pointer in [
|
||||
"",
|
||||
"/data",
|
||||
"/data/greeting",
|
||||
"/data/state",
|
||||
"/data/state/state",
|
||||
] {
|
||||
let mut changed = raw.clone();
|
||||
changed
|
||||
.pointer_mut(pointer)
|
||||
.unwrap()
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert(
|
||||
"hidden_reasoning".into(),
|
||||
serde_json::json!("must not reach artifact"),
|
||||
);
|
||||
assert!(
|
||||
validate_full_snapshot(&changed, &expected).is_err(),
|
||||
"{pointer}"
|
||||
);
|
||||
}
|
||||
let mut changed = raw;
|
||||
changed["data"]["greeting"]["private_key"] = serde_json::json!("must not reach artifact");
|
||||
assert!(validate_full_snapshot(&changed, &expected).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_snapshot_event_cannot_stand_in_for_live_snapshot() {
|
||||
let (_, expected) = sample();
|
||||
assert!(
|
||||
validate_full_snapshot(&serde_json::to_value(Event::Shutdown).unwrap(), &expected)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,13 +41,13 @@ fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_
|
||||
&event.data,
|
||||
&ready_ticket_id,
|
||||
"Planning E2E Ticket A",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
&& has_backend_ticket(
|
||||
&event.data,
|
||||
&planning_ticket_id,
|
||||
"Planning E2E Ticket B",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
@@ -82,13 +82,13 @@ fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_
|
||||
&event.data,
|
||||
&ready_ticket_id,
|
||||
"Planning E2E Ticket A",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
&& has_backend_ticket(
|
||||
&event.data,
|
||||
&planning_ticket_id,
|
||||
"Planning E2E Ticket B",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
@@ -137,7 +137,7 @@ fn backend_panel_shell_enter_path_uses_the_same_backend_authority() -> yoi_e2e::
|
||||
&event.data,
|
||||
&ready_ticket_id,
|
||||
"Planning E2E Ticket A",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Fixed, externally supplied Worker restore. Never generates a fixture or sends model input.
|
||||
#[path = "worker_restore/fixture.rs"]
|
||||
mod fixture;
|
||||
#[path = "worker_restore/seed.rs"]
|
||||
mod seed;
|
||||
|
||||
use std::fs;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fixture::{Fixture, failure};
|
||||
use seed::Installed;
|
||||
use server_api::{
|
||||
RuntimeWorkerLifecycleResult, RuntimeWorkerShowResponse, WorkerListResponse,
|
||||
WorkerOperationState, WorkerRestoreResponse, WorkerRestoreState, WorkerSummary,
|
||||
};
|
||||
use yoi_e2e::{FixtureSource, Result, WorkerE2eEnvironment, WorkerSessionSubscription};
|
||||
|
||||
#[test]
|
||||
fn supplied_stopped_worker_restores_exact_live_session_and_cleans_up() -> Result<()> {
|
||||
let fixture = Fixture::load()?;
|
||||
let result = (|| {
|
||||
let installed = seed::start(&fixture)?;
|
||||
let subscription = restore(&installed, &fixture, "fixed-fixture-restore-single")?;
|
||||
stop_and_cleanup(installed, vec![subscription], &fixture)
|
||||
})();
|
||||
fixture.source.assert_unchanged()?;
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_fixed_id_restores_keep_sessions_storage_and_cleanup_isolated() -> Result<()> {
|
||||
let fixture = Fixture::load()?;
|
||||
let result = (|| {
|
||||
let (first, second) = std::thread::scope(|scope| {
|
||||
let first = scope.spawn(|| seed::start(&fixture));
|
||||
let second = seed::start(&fixture);
|
||||
(
|
||||
first
|
||||
.join()
|
||||
.map_err(|_| failure("first environment startup panicked"))
|
||||
.and_then(|result| result),
|
||||
second,
|
||||
)
|
||||
});
|
||||
let first = first?;
|
||||
let second = second?;
|
||||
assert_eq!(
|
||||
first.environment.workspace_id,
|
||||
second.environment.workspace_id
|
||||
);
|
||||
assert_eq!(first.environment.runtime_id, second.environment.runtime_id);
|
||||
assert_ne!(first.environment.root, second.environment.root);
|
||||
assert_ne!(
|
||||
first.environment.server_data_dir,
|
||||
second.environment.server_data_dir
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.runtime_data_dir,
|
||||
second.environment.runtime_data_dir
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.artifacts_dir,
|
||||
second.environment.artifacts_dir
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.account_handle,
|
||||
second.environment.account_handle
|
||||
);
|
||||
assert_ne!(first.runtime_public_key, second.runtime_public_key);
|
||||
assert_ne!(first.workspace_public_key, second.workspace_public_key);
|
||||
assert_ne!(
|
||||
first.environment.server_port()?,
|
||||
second.environment.server_port()?
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.runtime_port()?,
|
||||
second.environment.runtime_port()?
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.server_pid(),
|
||||
second.environment.server_pid()
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.runtime_pid(),
|
||||
second.environment.runtime_pid()
|
||||
);
|
||||
first
|
||||
.environment
|
||||
.assert_authentication_isolated_from(&second.environment)?;
|
||||
second
|
||||
.environment
|
||||
.assert_authentication_isolated_from(&first.environment)?;
|
||||
let (first_subscription, second_subscription) = std::thread::scope(|scope| {
|
||||
// Deliberately reuse request identity in isolated stores, just like fixed IDs.
|
||||
let a = scope.spawn(|| restore(&first, &fixture, "fixed-fixture-restore-concurrent"));
|
||||
let b = restore(&second, &fixture, "fixed-fixture-restore-concurrent");
|
||||
(
|
||||
a.join()
|
||||
.map_err(|_| failure("first restore panicked"))
|
||||
.and_then(|result| result),
|
||||
b,
|
||||
)
|
||||
});
|
||||
let first_subscription = first_subscription?;
|
||||
let mut second_subscription = second_subscription?;
|
||||
let second_session_root = second
|
||||
.environment
|
||||
.runtime_data_dir
|
||||
.join("runtime/workers")
|
||||
.join(&fixture.manifest.worker_id)
|
||||
.join("session");
|
||||
let second_session = FixtureSource::capture(&second_session_root)?;
|
||||
let first_root = first.environment.root.clone();
|
||||
let first_artifacts = first.environment.artifacts_dir.clone();
|
||||
stop_and_cleanup(first, vec![first_subscription], &fixture)?;
|
||||
assert!(!first_root.exists());
|
||||
assert!(first_artifacts.join("cleanup.json").is_file());
|
||||
assert!(second.environment.root.is_dir());
|
||||
assert!(second.environment.server_data_dir.is_dir());
|
||||
assert!(second.environment.runtime_data_dir.is_dir());
|
||||
assert!(
|
||||
second
|
||||
.environment
|
||||
.artifacts_dir
|
||||
.join("restored-session.json")
|
||||
.is_file()
|
||||
);
|
||||
second.environment.assert_connected()?;
|
||||
assert_single_state(&second.environment, &fixture, "idle")?;
|
||||
second_subscription.assert_alive()?;
|
||||
// A second live connection independently verifies content after peer cleanup.
|
||||
let mut reconnected = WorkerSessionSubscription::connect(
|
||||
&second.environment,
|
||||
&fixture.manifest.worker_id,
|
||||
&second.snapshot,
|
||||
)?;
|
||||
reconnected.assert_alive()?;
|
||||
second_session.assert_unchanged()?;
|
||||
stop_and_cleanup(second, vec![second_subscription, reconnected], &fixture)?;
|
||||
Ok(())
|
||||
})();
|
||||
fixture.source.assert_unchanged()?;
|
||||
result
|
||||
}
|
||||
|
||||
fn restore(
|
||||
installed: &Installed,
|
||||
fixture: &Fixture,
|
||||
request_id: &str,
|
||||
) -> Result<WorkerSessionSubscription> {
|
||||
phase(&installed.environment, "observe-stopped")?;
|
||||
let stopped = assert_single_state(&installed.environment, fixture, "stopped")?;
|
||||
let token = stopped
|
||||
.restore_observation_token
|
||||
.as_deref()
|
||||
.filter(|token| !token.is_empty())
|
||||
.ok_or_else(|| failure("stopped Worker did not expose a restore observation token"))?;
|
||||
phase(&installed.environment, "restore")?;
|
||||
let result: WorkerRestoreResponse = serde_json::from_value(
|
||||
installed
|
||||
.environment
|
||||
.restore_worker(&fixture.manifest.worker_id, request_id, token)?,
|
||||
)?;
|
||||
if result.workspace_id != fixture.manifest.workspace_id
|
||||
|| result.runtime_id != fixture.manifest.runtime_id
|
||||
|| result.worker_id != fixture.manifest.worker_id
|
||||
|| result.result.state != WorkerRestoreState::Accepted
|
||||
{
|
||||
return Err(failure(format!(
|
||||
"restore did not commit accepted: {:?}",
|
||||
result.result.state
|
||||
)));
|
||||
}
|
||||
assert_single_state(&installed.environment, fixture, "idle")?;
|
||||
let detail: RuntimeWorkerShowResponse = serde_json::from_value(
|
||||
installed
|
||||
.environment
|
||||
.worker_detail(&fixture.manifest.worker_id)?,
|
||||
)?;
|
||||
if detail.worker.worker_id != fixture.manifest.worker_id
|
||||
|| detail.worker.runtime_id != fixture.manifest.runtime_id
|
||||
|| detail.worker.state != "idle"
|
||||
|| detail
|
||||
.worker
|
||||
.worker_state
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.state != protocol::WorkerState::Idle)
|
||||
{
|
||||
return Err(failure(
|
||||
"restored Worker detail is not the single live idle execution",
|
||||
));
|
||||
}
|
||||
phase(&installed.environment, "subscribe-live-session")?;
|
||||
let mut subscription = WorkerSessionSubscription::connect(
|
||||
&installed.environment,
|
||||
&fixture.manifest.worker_id,
|
||||
&installed.snapshot,
|
||||
)?;
|
||||
subscription.assert_alive()?;
|
||||
phase(&installed.environment, "live-session-verified")?;
|
||||
Ok(subscription)
|
||||
}
|
||||
|
||||
fn assert_single_state(
|
||||
environment: &WorkerE2eEnvironment,
|
||||
fixture: &Fixture,
|
||||
state: &str,
|
||||
) -> Result<WorkerSummary> {
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
loop {
|
||||
let list: WorkerListResponse = serde_json::from_value(environment.list_workers()?)?;
|
||||
if list.workspace_id != fixture.manifest.workspace_id || list.items.len() > 1 {
|
||||
return Err(failure(
|
||||
"Workspace Worker list has wrong identity or duplicate Workers",
|
||||
));
|
||||
}
|
||||
if let Some(worker) = list.items.into_iter().next() {
|
||||
if worker.worker_id != fixture.manifest.worker_id
|
||||
|| worker.runtime_id != fixture.manifest.runtime_id
|
||||
{
|
||||
return Err(failure("Workspace listed an unexpected Worker identity"));
|
||||
}
|
||||
if worker.state == state {
|
||||
return Ok(worker);
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(failure(format!(
|
||||
"Worker list did not become exactly one {state} Worker"
|
||||
)));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_and_cleanup(
|
||||
installed: Installed,
|
||||
subscriptions: Vec<WorkerSessionSubscription>,
|
||||
fixture: &Fixture,
|
||||
) -> Result<()> {
|
||||
phase(&installed.environment, "stop")?;
|
||||
let result: RuntimeWorkerLifecycleResult = serde_json::from_value(
|
||||
installed
|
||||
.environment
|
||||
.stop_worker(&fixture.manifest.worker_id)?,
|
||||
)?;
|
||||
if result.state != WorkerOperationState::Accepted {
|
||||
return Err(failure("Worker stop was not accepted"));
|
||||
}
|
||||
installed.environment.assert_connected()?;
|
||||
for subscription in subscriptions {
|
||||
subscription.expect_stopped()?;
|
||||
// A transport close caused by Server/Runtime shutdown is not stop evidence.
|
||||
installed.environment.assert_connected()?;
|
||||
}
|
||||
assert_single_state(&installed.environment, fixture, "stopped")?;
|
||||
phase(&installed.environment, "shutdown-cleanup")?;
|
||||
let report = installed.environment.cleanup()?;
|
||||
if !report.cleanup_success || report.fixture_root_exists_after {
|
||||
return Err(failure("environment cleanup failed"));
|
||||
}
|
||||
if !report.report_path.is_file() {
|
||||
return Err(failure("cleanup evidence is missing"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn phase(environment: &WorkerE2eEnvironment, phase: &str) -> Result<()> {
|
||||
fs::write(
|
||||
environment.artifacts_dir.join("restore-phase.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({"phase":phase}))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use protocol::{SessionMessageRole, SessionSnapshot, SessionSnapshotEntryData};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use worker_runtime::test_support::StoppedWorkerTemplate;
|
||||
use yoi_e2e::{FixtureSource, HarnessError, Result};
|
||||
|
||||
pub const PROFILE: &str = "project:fixture-chat";
|
||||
const ASSETS: [&str; 3] = [
|
||||
"expected.json",
|
||||
"worker-template.json",
|
||||
"workspace-config.dcdl",
|
||||
];
|
||||
const MAX_ASSET_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub format_version: u32,
|
||||
pub source_revision: String,
|
||||
pub runtime_schema_version: u32,
|
||||
pub session_schema_version: u32,
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub session_id: String,
|
||||
pub segment_id: String,
|
||||
pub model: String,
|
||||
pub state: String,
|
||||
pub acquisition: String,
|
||||
pub source_file_sha256: BTreeMap<String, String>,
|
||||
pub files: BTreeMap<String, Asset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Asset {
|
||||
pub bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Expected {
|
||||
source_revision: String,
|
||||
model: String,
|
||||
worker_id: String,
|
||||
session_id: String,
|
||||
rows: Vec<Value>,
|
||||
}
|
||||
|
||||
pub struct Fixture {
|
||||
pub root: PathBuf,
|
||||
pub source: FixtureSource,
|
||||
pub manifest: Manifest,
|
||||
expected: Expected,
|
||||
}
|
||||
|
||||
pub fn failure(message: impl Into<String>) -> HarnessError {
|
||||
HarnessError::Protocol(message.into())
|
||||
}
|
||||
pub fn support<T, E: std::fmt::Display>(result: std::result::Result<T, E>) -> Result<T> {
|
||||
result.map_err(|error| failure(error.to_string()))
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn load() -> Result<Self> {
|
||||
// Candidate override is explicit and read-only. Missing assets are failures,
|
||||
// never skips, and no test generates or repairs a fixture.
|
||||
let root = std::env::var_os("YOI_E2E_RESTORE_FIXTURE")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(path)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/worker-codex-v1")
|
||||
});
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(&bounded_read(&root.join("manifest.json"))?)?;
|
||||
if manifest.format_version != 1
|
||||
|| manifest.state != "stopped"
|
||||
|| manifest.runtime_schema_version == 0
|
||||
|| manifest.session_schema_version == 0
|
||||
|| manifest.acquisition.is_empty()
|
||||
|| manifest.source_file_sha256.is_empty()
|
||||
|| manifest.source_revision.len() != 40
|
||||
|| !manifest
|
||||
.source_revision
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(failure("unsupported/incomplete fixture manifest"));
|
||||
}
|
||||
let names: BTreeSet<_> = manifest.files.keys().map(String::as_str).collect();
|
||||
if names != BTreeSet::from(ASSETS) {
|
||||
return Err(failure("unexpected fixture asset set"));
|
||||
}
|
||||
for name in ASSETS {
|
||||
bounded_read(&root.join(name))?;
|
||||
}
|
||||
let source = FixtureSource::capture(&root)?;
|
||||
if source.hashes().len() != 4 {
|
||||
return Err(failure("fixture has unmanifested files"));
|
||||
}
|
||||
for (name, asset) in &manifest.files {
|
||||
if asset.bytes != fs::metadata(root.join(name))?.len()
|
||||
|| source.hashes().get(Path::new(name)) != Some(&format!("sha256:{}", asset.sha256))
|
||||
{
|
||||
return Err(failure(format!("fixture asset hash/size mismatch: {name}")));
|
||||
}
|
||||
}
|
||||
let expected: Expected =
|
||||
serde_json::from_slice(&bounded_read(&root.join("expected.json"))?)?;
|
||||
if expected.source_revision != manifest.source_revision
|
||||
|| expected.model != manifest.model
|
||||
|| expected.worker_id != manifest.worker_id
|
||||
|| expected.session_id != manifest.session_id
|
||||
|| expected.rows.is_empty()
|
||||
{
|
||||
return Err(failure(
|
||||
"expected conversation identity differs from manifest",
|
||||
));
|
||||
}
|
||||
verify_builtin_revision(&manifest.source_revision)?;
|
||||
let fixture = Self {
|
||||
root,
|
||||
source,
|
||||
manifest,
|
||||
expected,
|
||||
};
|
||||
fixture.template(&fixture.root)?;
|
||||
Ok(fixture)
|
||||
}
|
||||
|
||||
pub fn template(&self, copy: &Path) -> Result<StoppedWorkerTemplate> {
|
||||
let bytes = bounded_read(©.join("worker-template.json"))?;
|
||||
let template: StoppedWorkerTemplate = serde_json::from_slice(&bytes)?;
|
||||
let shape: Value = serde_json::from_slice(&bytes)?;
|
||||
let session_manifest_bytes: Vec<u8> =
|
||||
serde_json::from_value(shape["session_manifest"].clone())?;
|
||||
let segment_bytes: Vec<u8> = serde_json::from_value(shape["segment_jsonl"].clone())?;
|
||||
for (name, bytes) in [
|
||||
(
|
||||
"session/session.json".to_owned(),
|
||||
session_manifest_bytes.as_slice(),
|
||||
),
|
||||
(
|
||||
format!("session/segments/{}.jsonl", self.manifest.segment_id),
|
||||
segment_bytes.as_slice(),
|
||||
),
|
||||
] {
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
if self.manifest.source_file_sha256.get(&name) != Some(&digest) {
|
||||
return Err(failure(
|
||||
"exported Session bytes differ from acquisition hash evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
let session_manifest: Value = serde_json::from_slice(&session_manifest_bytes)?;
|
||||
let m = &self.manifest;
|
||||
if template.workspace_id() != m.workspace_id
|
||||
|| template.runtime_id() != m.runtime_id
|
||||
|| template.worker_id().to_string() != m.worker_id
|
||||
|| template.session_id().to_string() != m.session_id
|
||||
|| template.segment_id().to_string() != m.segment_id
|
||||
|| shape["runtime_schema_version"] != m.runtime_schema_version
|
||||
|| session_manifest["schema_version"] != m.session_schema_version
|
||||
{
|
||||
return Err(failure("template schema/identity differs from manifest"));
|
||||
}
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
pub fn verify_conversation(&self, snapshot: &SessionSnapshot) -> Result<()> {
|
||||
let mut rows = Vec::new();
|
||||
for entry in &snapshot.entries {
|
||||
let (role, parts) = match &entry.data {
|
||||
SessionSnapshotEntryData::UserInput { segments } => {
|
||||
("user", serde_json::to_value(segments)?)
|
||||
}
|
||||
SessionSnapshotEntryData::Message {
|
||||
role: SessionMessageRole::Assistant,
|
||||
content,
|
||||
} => ("assistant", serde_json::to_value(content)?),
|
||||
// Display-only accounting is not a conversation row. Its exact
|
||||
// IDs, timestamp and values are still checked in the full WS snapshot.
|
||||
SessionSnapshotEntryData::RunStats { .. } => continue,
|
||||
_ => {
|
||||
return Err(failure(
|
||||
"unexpected non-conversation entry in full Session snapshot",
|
||||
));
|
||||
}
|
||||
};
|
||||
rows.push(json!({"entry_id":entry.entry_id,"timestamp":entry.timestamp,"role":role,"parts":parts}));
|
||||
}
|
||||
if rows != self.expected.rows {
|
||||
return Err(failure(
|
||||
"full public conversation rows differ from expected IDs/time/roles/text/order",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_read(path: &Path) -> Result<Vec<u8>> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||||
failure(format!(
|
||||
"required fixture asset {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_ASSET_BYTES {
|
||||
return Err(failure("fixture asset is not a bounded regular file"));
|
||||
}
|
||||
Ok(fs::read(path)?)
|
||||
}
|
||||
|
||||
fn verify_builtin_revision(revision: &str) -> Result<()> {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap();
|
||||
// Resolve the immutable commit before diff; bad revisions must not be treated
|
||||
// as an empty diff. All resources are checked, including transitive imports.
|
||||
let commit = Command::new("git")
|
||||
.args(["cat-file", "-t", revision])
|
||||
.current_dir(workspace)
|
||||
.output()?;
|
||||
if !commit.status.success() || commit.stdout != b"commit\n" {
|
||||
return Err(failure(
|
||||
"fixture source revision is unavailable for builtin verification",
|
||||
));
|
||||
}
|
||||
let status = Command::new("git")
|
||||
.args([
|
||||
"diff",
|
||||
"--quiet",
|
||||
revision,
|
||||
"--",
|
||||
"resources",
|
||||
"crates/builtin-source",
|
||||
])
|
||||
.current_dir(workspace)
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
return Err(failure(
|
||||
"builtin/config resources differ from fixture source revision; provide a new version or frozen references",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use config_source::{ConfigTreeChange, VirtualPath};
|
||||
use protocol::SessionSnapshot;
|
||||
use serde_json::Value;
|
||||
use worker_runtime::FsRuntimeStoreOptions;
|
||||
use worker_runtime::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource,
|
||||
WorkspaceApiRef,
|
||||
};
|
||||
use yoi_e2e::{Result, WorkerE2eEnvironment};
|
||||
use yoi_workspace_server::auth::{mint_secret, new_id, now_rfc3339, token_hash};
|
||||
use yoi_workspace_server::config_source::{ConfigCommitRequest, WorkspaceConfigSchemaRegistry};
|
||||
use yoi_workspace_server::hosts::{
|
||||
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
|
||||
};
|
||||
use yoi_workspace_server::profile_settings::{
|
||||
ProfileConfigSchemaProvider, build_virtual_profile_config_bundle,
|
||||
project_profiles_from_workspace_config,
|
||||
};
|
||||
use yoi_workspace_server::store::{
|
||||
AccountRecord, ApiTokenRecord, UserRecord, WorkspaceBootstrapRecord,
|
||||
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
|
||||
};
|
||||
use yoi_workspace_server::test_support::{FixedWorkerSeed, seed_fixed_worker};
|
||||
use yoi_workspace_server::workspace_signing_identity::{
|
||||
FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
|
||||
workspace_signing_material_root,
|
||||
};
|
||||
use yoi_workspace_server::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||
|
||||
use super::fixture::{Fixture, PROFILE, failure, support};
|
||||
|
||||
pub struct Installed {
|
||||
pub environment: WorkerE2eEnvironment,
|
||||
pub snapshot: SessionSnapshot,
|
||||
pub runtime_public_key: String,
|
||||
pub workspace_public_key: String,
|
||||
}
|
||||
|
||||
pub fn start(fixture: &Fixture) -> Result<Installed> {
|
||||
let mut environment = WorkerE2eEnvironment::new()?;
|
||||
super::phase(&environment, "fixture-copy-verify")?;
|
||||
let copy = environment.root.join("provided-fixture");
|
||||
fixture.source.copy_to(©)?;
|
||||
fs::write(
|
||||
environment.artifacts_dir.join("fixture-manifest.json"),
|
||||
fs::read(copy.join("manifest.json"))?,
|
||||
)?;
|
||||
let template = fixture.template(©)?;
|
||||
let runtime_identity =
|
||||
environment.initialize_runtime_identity(fixture.manifest.runtime_id.clone())?;
|
||||
let runtime_public_key = runtime_identity["public_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| failure("Runtime public bundle omitted key"))?
|
||||
.to_owned();
|
||||
|
||||
// Reserve an environment-local placeholder endpoint only while the Runtime is
|
||||
// offline. Its real :0 endpoint replaces this binding through public API CAS.
|
||||
let reserved_endpoint = TcpListener::bind("127.0.0.1:0")?;
|
||||
super::phase(
|
||||
&environment,
|
||||
"offline-backend-bootstrap-profile-worker-seed",
|
||||
)?;
|
||||
let bootstrap = bootstrap(
|
||||
&environment,
|
||||
fixture,
|
||||
©,
|
||||
&runtime_identity,
|
||||
&format!("http://{}", reserved_endpoint.local_addr()?),
|
||||
&template.worker_name(),
|
||||
)?;
|
||||
// Close the placeholder before Backend probes it: an accepting TCP listener
|
||||
// with no HTTP service would stall discovery until its transport timeout.
|
||||
drop(reserved_endpoint);
|
||||
super::phase(&environment, "start-seeded-backend")?;
|
||||
environment.start_seeded_backend(
|
||||
fixture.manifest.workspace_id.clone(),
|
||||
fixture.manifest.runtime_id.clone(),
|
||||
bootstrap.token,
|
||||
)?;
|
||||
let mut snapshot = None;
|
||||
super::phase(&environment, "initialize-runtime-trust")?;
|
||||
environment.start_runtime_with_seed(|environment| {
|
||||
super::phase(environment, "install-runtime-fixture-and-validate-session")?;
|
||||
let options = FsRuntimeStoreOptions::new(environment.runtime_data_dir.join("runtime"))
|
||||
.with_runtime_id(&fixture.manifest.runtime_id);
|
||||
let request = CreateWorkerRequest {
|
||||
worker_id: template.worker_id(),
|
||||
create_fingerprint: bootstrap.create_fingerprint.clone(),
|
||||
profile: ProfileSelector::Named(PROFILE.into()),
|
||||
display_name: Some("Restore E2E fixture".into()),
|
||||
profile_source: ProfileSourceArchiveSource::WorkspaceConfig {
|
||||
archive: bootstrap.archive.clone(),
|
||||
},
|
||||
config_bundle: Some(bootstrap.config_bundle.clone()),
|
||||
workspace_api: Some(WorkspaceApiRef {
|
||||
workspace_id: fixture.manifest.workspace_id.clone(),
|
||||
base_url: environment.backend_target()?.0.into(),
|
||||
}),
|
||||
initial_input: None,
|
||||
workdir_attachment_requests: vec![],
|
||||
workdir_attachments: vec![],
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: vec![],
|
||||
memory_settings: Some(bootstrap.memory_settings.clone()),
|
||||
subjektiv_attached: false,
|
||||
backend_job: None,
|
||||
};
|
||||
support(template.install(&options, request, &bootstrap.manifest))?;
|
||||
let sessions = support(session_store::WorkerSessionStore::open_read_only(
|
||||
options
|
||||
.root
|
||||
.join("workers")
|
||||
.join(template.worker_id().to_string())
|
||||
.join("session"),
|
||||
))?;
|
||||
let (logs, _) = support(sessions.read_all_read_only_bounded(
|
||||
template.session_id(),
|
||||
template.segment_id(),
|
||||
8 * 1024 * 1024,
|
||||
))?;
|
||||
let projected = session_store::project_session_snapshot(template.session_id(), &logs);
|
||||
fixture.verify_conversation(&projected)?;
|
||||
snapshot = Some(full_restored_snapshot(&sessions, &logs));
|
||||
Ok(())
|
||||
})?;
|
||||
fixture.source.assert_unchanged()?;
|
||||
Ok(Installed {
|
||||
environment,
|
||||
snapshot: snapshot.ok_or_else(|| failure("Runtime seed was not invoked"))?,
|
||||
runtime_public_key,
|
||||
workspace_public_key: bootstrap.workspace_public_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn full_restored_snapshot(
|
||||
store: &dyn session_store::Store,
|
||||
logs: &[session_store::LogEntry],
|
||||
) -> SessionSnapshot {
|
||||
session_store::public_snapshot::project_current_session_snapshot_with_accounting(store, logs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use protocol::{
|
||||
Event, Greeting, InFlightSnapshot, InvokeKind, SessionSnapshotEntryData,
|
||||
WorkerStateSnapshot, WorkerStatus,
|
||||
};
|
||||
use session_store::{
|
||||
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
|
||||
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn completed_run_full_snapshot_retains_invoke_and_usage_accounting() -> Result<()> {
|
||||
// Projection regression only: no saved Worker or substitute E2E fixture.
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store = support(session_store::WorkerSessionStore::new(temp.path()))?;
|
||||
let session_id = session_store::new_session_id();
|
||||
let logs = vec![
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: Default::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::Invoke {
|
||||
ts: 10,
|
||||
trigger: InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 20,
|
||||
entry: LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::Assistant,
|
||||
content: vec![LoggedContentPart::Text {
|
||||
text: "unit response".into(),
|
||||
}],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId("unit-response".into()),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
input_metadata: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
LogEntry::LlmUsage {
|
||||
ts: 30,
|
||||
history_len: 1,
|
||||
input_total_tokens: 100,
|
||||
cache_read_tokens: 20,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 5,
|
||||
},
|
||||
serde_json::from_value(serde_json::json!({"kind":"run_completed", "ts":40,
|
||||
"interrupted":false,"result":"finished"}))?,
|
||||
];
|
||||
let conversation = session_store::project_session_snapshot(session_id, &logs);
|
||||
let expected = full_restored_snapshot(&store, &logs);
|
||||
assert_eq!(expected.entries.len(), conversation.entries.len() + 2);
|
||||
assert!(matches!(
|
||||
expected.entries[expected.entries.len() - 2].data,
|
||||
SessionSnapshotEntryData::Invoke {
|
||||
trigger: InvokeKind::UserSend
|
||||
}
|
||||
));
|
||||
assert_eq!(expected.entries[expected.entries.len() - 2].timestamp, 10);
|
||||
assert!(matches!(
|
||||
expected.entries.last().unwrap().data,
|
||||
SessionSnapshotEntryData::Usage {
|
||||
input_tokens: 100,
|
||||
cache_read_input_tokens: 20,
|
||||
output_tokens: 5
|
||||
}
|
||||
));
|
||||
assert_eq!(expected.entries.last().unwrap().timestamp, 30);
|
||||
let live = session_store::public_snapshot::project_current_session_snapshot_with_accounting(
|
||||
&store, &logs,
|
||||
);
|
||||
let event = Event::Snapshot {
|
||||
session: live,
|
||||
state: WorkerStateSnapshot::from(WorkerStatus::Idle),
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
internal_workers: vec![],
|
||||
greeting: Greeting {
|
||||
worker_name: "unit".into(),
|
||||
cwd: ".".into(),
|
||||
provider: "unit".into(),
|
||||
model: "unit".into(),
|
||||
reasoning: None,
|
||||
scope_summary: "none".into(),
|
||||
tools: vec![],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
};
|
||||
let raw = serde_json::to_value(event)?;
|
||||
yoi_e2e::validate_full_snapshot(&raw, &expected)?;
|
||||
assert!(yoi_e2e::validate_full_snapshot(&raw, &conversation).is_err());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Bootstrap {
|
||||
token: String,
|
||||
workspace_public_key: String,
|
||||
create_fingerprint: String,
|
||||
archive: worker_runtime::profile_archive::ProfileSourceArchiveRef,
|
||||
config_bundle: ConfigBundleRef,
|
||||
manifest: manifest::WorkerManifest,
|
||||
memory_settings: manifest::WorkspaceMemorySettingsSnapshot,
|
||||
}
|
||||
|
||||
fn bootstrap(
|
||||
environment: &WorkerE2eEnvironment,
|
||||
fixture: &Fixture,
|
||||
copy: &Path,
|
||||
runtime_identity: &Value,
|
||||
endpoint: &str,
|
||||
worker_name: &str,
|
||||
) -> Result<Bootstrap> {
|
||||
fs::create_dir_all(&environment.server_data_dir)?;
|
||||
let database = environment.server_data_dir.join("server.db");
|
||||
if database.exists() {
|
||||
return Err(failure("Backend seed refuses an existing database"));
|
||||
}
|
||||
let store = support(SqliteWorkspaceStore::open(&database))?;
|
||||
let now = now_rfc3339();
|
||||
let account_id = new_id("acct-user");
|
||||
let user_id = new_id("user");
|
||||
support(store.upsert_account(&AccountRecord {
|
||||
account_id: account_id.clone(),
|
||||
kind: "user".into(),
|
||||
handle: environment.account_handle.clone(),
|
||||
display_name: "Restore E2E owner".into(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
}))?;
|
||||
support(store.upsert_user(&UserRecord {
|
||||
user_id: user_id.clone(),
|
||||
account_id: account_id.clone(),
|
||||
handle: environment.account_handle.clone(),
|
||||
display_name: "Restore E2E owner".into(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
}))?;
|
||||
let token = mint_secret("yoi_api");
|
||||
support(store.create_api_token(&ApiTokenRecord {
|
||||
token_id: new_id("api-token"),
|
||||
token_hash: token_hash(&token),
|
||||
user_id,
|
||||
label: "restore-e2e".into(),
|
||||
created_at: now.clone(),
|
||||
expires_at: None,
|
||||
revoked_at: None,
|
||||
last_used_at: None,
|
||||
}))?;
|
||||
let identities = WorkspaceSigningIdentityService::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(FsWorkspaceSigningMaterialStore::new(
|
||||
workspace_signing_material_root(&database),
|
||||
)),
|
||||
);
|
||||
let (identity, operation) = support(identities.prepare_workspace_creation(
|
||||
"restore-e2e-bootstrap",
|
||||
"restore-e2e-bootstrap",
|
||||
&fixture.manifest.workspace_id,
|
||||
&account_id,
|
||||
))?;
|
||||
let workspace_public_key = identity.public_key.clone();
|
||||
support(store.create_workspace_bootstrap(
|
||||
&WorkspaceBootstrapRecord {
|
||||
operation_key: "restore-e2e-bootstrap".into(),
|
||||
request_fingerprint: "restore-e2e-bootstrap".into(),
|
||||
workspace: WorkspaceRecord {
|
||||
workspace_id: fixture.manifest.workspace_id.clone(),
|
||||
owner_account_id: account_id,
|
||||
display_name: "Restore E2E".into(),
|
||||
state: "active".into(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
},
|
||||
repository: None,
|
||||
},
|
||||
&identity,
|
||||
&operation,
|
||||
))?;
|
||||
support(
|
||||
store.upsert_workspace_runtime_binding(
|
||||
WorkspaceRuntimeBinding {
|
||||
workspace_id: fixture.manifest.workspace_id.clone(),
|
||||
runtime_id: fixture.manifest.runtime_id.clone(),
|
||||
display_name: format!("E2E {}", fixture.manifest.runtime_id),
|
||||
base_url: endpoint.into(),
|
||||
public_key: runtime_identity["public_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| failure("missing Runtime public key"))?
|
||||
.into(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: String::new(),
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
revoked_at: None,
|
||||
},
|
||||
false,
|
||||
),
|
||||
)?;
|
||||
|
||||
let schema = support(
|
||||
WorkspaceConfigSchemaRegistry::default()
|
||||
.with_provider(Arc::new(ProfileConfigSchemaProvider))
|
||||
.with_provider(Arc::new(
|
||||
yoi_workspace_server::prompt_settings::PromptConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(
|
||||
yoi_workspace_server::runtime_settings::RuntimeConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(
|
||||
yoi_workspace_server::repository_access::RepositoryAccessConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(
|
||||
yoi_workspace_server::skills::SkillConfigSchemaProvider,
|
||||
))
|
||||
.compose(),
|
||||
)?;
|
||||
let state = support(store.ensure_workspace_config_materialized_with_schema(
|
||||
&fixture.manifest.workspace_id,
|
||||
&now,
|
||||
schema.clone(),
|
||||
))?;
|
||||
let main = support(VirtualPath::parse("main.dcdl"))?;
|
||||
let current = state
|
||||
.snapshot
|
||||
.get(&main)
|
||||
.ok_or_else(|| failure("Workspace config omitted main.dcdl"))?;
|
||||
let candidate = support(store.evaluate_workspace_config_candidate_with_schema(
|
||||
&fixture.manifest.workspace_id,
|
||||
&ConfigCommitRequest {
|
||||
base_digest: state.snapshot.digest.clone(),
|
||||
changes: vec![ConfigTreeChange::Update {
|
||||
path: main.clone(),
|
||||
expected_digest: current.content_digest.clone(),
|
||||
content: fs::read_to_string(copy.join("workspace-config.dcdl"))?,
|
||||
}],
|
||||
entrypoints: vec![main],
|
||||
},
|
||||
schema,
|
||||
))?;
|
||||
let state = support(
|
||||
store.commit_evaluated_workspace_config(&fixture.manifest.workspace_id, &candidate),
|
||||
)?;
|
||||
let projection = support(project_profiles_from_workspace_config(
|
||||
&fixture.manifest.workspace_id,
|
||||
&state,
|
||||
))?;
|
||||
let bundle = support(build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&state,
|
||||
&fixture.manifest.workspace_id,
|
||||
&now,
|
||||
PROFILE,
|
||||
))?
|
||||
.ok_or_else(|| failure("fixture Profile is not registered"))?;
|
||||
let archive = bundle
|
||||
.profile_source_archive
|
||||
.as_ref()
|
||||
.ok_or_else(|| failure("fixture Profile archive is absent"))?;
|
||||
let mut resolved = support(
|
||||
support(archive.verify())?.resolve_profile(
|
||||
PROFILE,
|
||||
&environment
|
||||
.runtime_data_dir
|
||||
.join("runtime/workers")
|
||||
.join(&fixture.manifest.worker_id),
|
||||
worker_name,
|
||||
),
|
||||
)?;
|
||||
if resolved.model.ref_.as_deref() != Some(fixture.manifest.model.as_str()) {
|
||||
return Err(failure("resolved Profile changed the supplied model"));
|
||||
}
|
||||
let request = WorkerSpawnRequest {
|
||||
intent: WorkerSpawnIntent::WorkspaceCoding,
|
||||
requested_worker_name: Some("Restore E2E fixture".into()),
|
||||
singleton_key: None,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
|
||||
profile: ProfileSelector::Named(PROFILE.into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: vec![],
|
||||
workdir_attachment_requests: vec![],
|
||||
resolved_workdir_attachment_requests: vec![],
|
||||
resolved_workdir_attachments: vec![],
|
||||
resolved_config_bundle: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: vec![],
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
};
|
||||
let reservation = support(seed_fixed_worker(
|
||||
&store,
|
||||
FixedWorkerSeed {
|
||||
workspace_id: &fixture.manifest.workspace_id,
|
||||
runtime_id: &fixture.manifest.runtime_id,
|
||||
worker_id: support(fixture.manifest.worker_id.parse())?,
|
||||
allocation_key: "restore-e2e-worker",
|
||||
request: &request,
|
||||
},
|
||||
))?;
|
||||
if resolved.feature.memory.profile.enabled {
|
||||
support(
|
||||
resolved
|
||||
.feature
|
||||
.memory
|
||||
.bind_workspace_settings(reservation.memory_settings.clone()),
|
||||
)?;
|
||||
}
|
||||
Ok(Bootstrap {
|
||||
manifest: resolved,
|
||||
memory_settings: reservation.memory_settings,
|
||||
token,
|
||||
workspace_public_key,
|
||||
create_fingerprint: reservation.create_fingerprint,
|
||||
archive: archive.reference.clone(),
|
||||
config_bundle: ConfigBundleRef {
|
||||
id: bundle.metadata.id,
|
||||
digest: bundle.metadata.digest,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user