fix: make WorkerId the standalone primary identity

This commit is contained in:
2026-08-31 14:37:46 +09:00
parent bde1dea2a5
commit e7f4c6864f
16 changed files with 444 additions and 437 deletions
Generated
+1
View File
@@ -3508,6 +3508,7 @@ dependencies = [
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"tokio", "tokio",
"ts-rs", "ts-rs",
"uuid", "uuid",
+3 -3
View File
@@ -35,9 +35,9 @@ pub use backend_workspace::{
}; };
pub use client::{Client, ClientError}; pub use client::{Client, ClientError};
pub use target::{ pub use target::{
BackendTarget, Dashboard, ResolvedTarget, StandaloneSessionListIntent, BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent,
StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
}; };
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary}; pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
pub use workspace_product::BackendWorkspaceProductClient; pub use workspace_product::BackendWorkspaceProductClient;
+24 -24
View File
@@ -105,16 +105,16 @@ pub struct WorkerSpawn {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct StandaloneSessionListIntent { pub struct StandaloneWorkerListIntent {
pub state_dir: PathBuf, pub state_dir: PathBuf,
pub cwd: PathBuf, pub cwd: PathBuf,
pub include_all: bool, pub include_all: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct StandaloneSessionResumeIntent { pub struct StandaloneWorkerResumeIntent {
pub state_dir: PathBuf, pub state_dir: PathBuf,
pub session_id: String, pub worker_id: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -175,22 +175,22 @@ pub trait Target: fmt::Debug + Send + Sync {
Err(TargetError::unsupported("Worker spawn", self.kind())) Err(TargetError::unsupported("Worker spawn", self.kind()))
} }
fn standalone_session_list( fn standalone_worker_list(
&self, &self,
_include_all: bool, _include_all: bool,
) -> Result<StandaloneSessionListIntent, TargetError> { ) -> Result<StandaloneWorkerListIntent, TargetError> {
Err(TargetError::unsupported( Err(TargetError::unsupported(
"standalone session listing", "standalone Worker listing",
self.kind(), self.kind(),
)) ))
} }
fn standalone_session_resume( fn standalone_worker_resume(
&self, &self,
_session_id: String, _worker_id: String,
) -> Result<StandaloneSessionResumeIntent, TargetError> { ) -> Result<StandaloneWorkerResumeIntent, TargetError> {
Err(TargetError::unsupported( Err(TargetError::unsupported(
"standalone session restore", "standalone Worker restore",
self.kind(), self.kind(),
)) ))
} }
@@ -243,26 +243,26 @@ impl Target for StandaloneTarget {
}) })
} }
fn standalone_session_list( fn standalone_worker_list(
&self, &self,
include_all: bool, include_all: bool,
) -> Result<StandaloneSessionListIntent, TargetError> { ) -> Result<StandaloneWorkerListIntent, TargetError> {
let cwd = std::env::current_dir() let cwd = std::env::current_dir()
.map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?; .map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?;
Ok(StandaloneSessionListIntent { Ok(StandaloneWorkerListIntent {
state_dir: self.state_dir.clone(), state_dir: self.state_dir.clone(),
cwd, cwd,
include_all, include_all,
}) })
} }
fn standalone_session_resume( fn standalone_worker_resume(
&self, &self,
session_id: String, worker_id: String,
) -> Result<StandaloneSessionResumeIntent, TargetError> { ) -> Result<StandaloneWorkerResumeIntent, TargetError> {
Ok(StandaloneSessionResumeIntent { Ok(StandaloneWorkerResumeIntent {
state_dir: self.state_dir.clone(), state_dir: self.state_dir.clone(),
session_id, worker_id,
}) })
} }
} }
@@ -437,17 +437,17 @@ mod tests {
} }
#[test] #[test]
fn standalone_target_builds_explicit_session_intents() { fn standalone_target_builds_explicit_worker_intents() {
let target = StandaloneTarget::new("/tmp/yoi-client-sessions"); let target = StandaloneTarget::new("/tmp/yoi-client-workers");
let list = target.standalone_session_list(true).unwrap(); let list = target.standalone_worker_list(true).unwrap();
assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-sessions")); assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-workers"));
assert!(list.include_all); assert!(list.include_all);
assert!(list.cwd.is_absolute()); assert!(list.cwd.is_absolute());
let resume = target let resume = target
.standalone_session_resume("019d1234-0000-7000-8000-000000000000".to_string()) .standalone_worker_resume("019d1234-0000-7000-8000-000000000000".to_string())
.unwrap(); .unwrap();
assert_eq!(resume.state_dir, list.state_dir); assert_eq!(resume.state_dir, list.state_dir);
assert_eq!(resume.session_id, "019d1234-0000-7000-8000-000000000000"); assert_eq!(resume.worker_id, "019d1234-0000-7000-8000-000000000000");
} }
} }
+2 -1
View File
@@ -14,6 +14,7 @@ json-schema = ["dep:schemars"]
schemars = { workspace = true, optional = true } schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2.workspace = true
tokio = { workspace = true, features = ["io-util"], optional = true } tokio = { workspace = true, features = ["io-util"], optional = true }
ts-rs = { version = "12.0.1", optional = true } ts-rs = { version = "12.0.1", optional = true }
uuid = { workspace = true, features = ["serde"] } uuid = { workspace = true, features = ["serde", "v7"] }
+132
View File
@@ -0,0 +1,132 @@
use std::{fmt, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use sha2::{Digest, Sha256};
use uuid::{Uuid, Version};
/// Stable Worker identity independent of its current Runtime placement or
/// conversation Session.
///
/// Workspace authority allocates this ID for managed Workers. A standalone
/// Worker store allocates it locally when no Workspace authority is present.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct WorkerId(Uuid);
impl WorkerId {
pub fn now_v7() -> Self {
Self(Uuid::now_v7())
}
/// Converts a legacy Runtime-local numeric id into a syntactically valid
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
pub fn from_legacy_u64(value: u64) -> Self {
let mut bytes = [0_u8; 16];
bytes[8..].copy_from_slice(&value.to_be_bytes());
bytes[6] = 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
}
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
let mut hasher = Sha256::new();
hasher.update(b"yoi.workspace-worker-id.v1\0");
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(runtime_id.as_bytes());
hasher.update([0]);
hasher.update(value.to_be_bytes());
let digest = hasher.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
// Migrated ids sort before normally allocated UUIDv7 values while retaining
// deterministic collision-resistant payload bits.
bytes[..6].fill(0);
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
}
pub fn parse(value: &str) -> Option<Self> {
let value = Uuid::parse_str(value).ok()?;
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
}
pub const fn as_uuid(self) -> Uuid {
self.0
}
#[must_use]
pub fn short(self) -> String {
let simple = self.0.simple().to_string();
simple[simple.len() - 12..].to_string()
}
}
impl fmt::Display for WorkerId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
impl FromStr for WorkerId {
type Err = WorkerIdParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value).ok_or(WorkerIdParseError)
}
}
impl Serialize for WorkerId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for WorkerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkerIdParseError;
impl fmt::Display for WorkerIdParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("Worker id must be a UUIDv7")
}
}
impl std::error::Error for WorkerIdParseError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worker_id_accepts_only_uuid_v7() {
let worker_id = WorkerId::now_v7();
assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
assert!(WorkerId::parse("30").is_none());
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
}
#[test]
fn legacy_worker_id_mapping_is_stable() {
assert_eq!(
WorkerId::from_legacy_binding("workspace", "runtime", 42),
WorkerId::from_legacy_binding("workspace", "runtime", 42)
);
assert_ne!(
WorkerId::from_legacy_binding("workspace", "runtime", 42),
WorkerId::from_legacy_binding("workspace", "runtime", 43)
);
}
}
+3
View File
@@ -1,3 +1,4 @@
pub mod identity;
#[cfg(feature = "stream")] #[cfg(feature = "stream")]
pub mod stream; pub mod stream;
pub mod subscription; pub mod subscription;
@@ -8,6 +9,8 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use identity::{WorkerId, WorkerIdParseError};
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
+71 -69
View File
@@ -5,7 +5,7 @@ use agen::llm_client::client::LlmClient;
use client::Client; use client::Client;
use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket}; use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket};
use protocol::stream::{decode_method, encode_event}; use protocol::stream::{decode_method, encode_event};
use protocol::{Event, Method}; use protocol::{Event, Method, WorkerId};
use session_store::{ use session_store::{
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore, CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
}; };
@@ -20,14 +20,14 @@ use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerW
use crate::launch::ResolvedStandaloneLaunch; use crate::launch::ResolvedStandaloneLaunch;
use crate::store::{ use crate::store::{
StaleLeasePolicy, StandaloneSessionId, StandaloneSessionLease, StandaloneSessionRecord, StaleLeasePolicy, StandaloneShutdownReason, StandaloneStoreError, StandaloneWorkerLease,
StandaloneSessionStore, StandaloneShutdownReason, StandaloneStoreError, StandaloneWorkerRecord, StandaloneWorkerStore,
}; };
const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
type StandaloneBackingStore = CombinedStore<FsStore, FsWorkerStore>; type StandaloneBackingStore = CombinedStore<FsStore, FsWorkerStore>;
/// One client-owned top-level Worker and its standalone session authority. /// One client-owned top-level Worker and its standalone Worker authority.
/// ///
/// The host deliberately exposes the existing typed Worker protocol rather than owning an /// The host deliberately exposes the existing typed Worker protocol rather than owning an
/// HTTP/WebSocket server or creating Runtime/Workspace/Ticket/Workdir domain records. /// HTTP/WebSocket server or creating Runtime/Workspace/Ticket/Workdir domain records.
@@ -35,21 +35,21 @@ pub struct StandaloneHost {
handle: worker::WorkerHandle, handle: worker::WorkerHandle,
shutdown: Option<worker::controller::ShutdownReceiver>, shutdown: Option<worker::controller::ShutdownReceiver>,
shutdown_timeout: Duration, shutdown_timeout: Duration,
store: StandaloneSessionStore, store: StandaloneWorkerStore,
worker_store: FsWorkerStore, worker_store: FsWorkerStore,
record: StandaloneSessionRecord, record: StandaloneWorkerRecord,
lease: Option<StandaloneSessionLease>, lease: Option<StandaloneWorkerLease>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum StandaloneStartupError { pub enum StandaloneStartupError {
#[error("the standalone state store could not be opened or validated")] #[error("the standalone state store could not be opened or validated")]
StateStore, StateStore,
#[error("the standalone session is already active")] #[error("the standalone Worker is already active")]
SessionActive, WorkerActive,
#[error("the standalone session lease cannot be observed safely; recovery is rejected")] #[error("the standalone Worker lease cannot be observed safely; recovery is rejected")]
LeaseLivenessUnknown, LeaseLivenessUnknown,
#[error("the standalone session working directory is unavailable or changed")] #[error("the standalone Worker working directory is unavailable or changed")]
WorkingDirectoryUnavailable, WorkingDirectoryUnavailable,
#[error("the resolved Worker configuration or persisted history is invalid")] #[error("the resolved Worker configuration or persisted history is invalid")]
WorkerConfiguration, WorkerConfiguration,
@@ -67,7 +67,7 @@ pub enum StandaloneShutdownError {
DeadlineExceeded, DeadlineExceeded,
#[error("the standalone Worker shutdown confirmation was lost")] #[error("the standalone Worker shutdown confirmation was lost")]
ConfirmationLost, ConfirmationLost,
#[error("the standalone session final state could not be committed")] #[error("the standalone Worker final state could not be committed")]
StateStore, StateStore,
} }
@@ -87,22 +87,24 @@ impl StandaloneHost {
} }
async fn start_with_optional_model_client( async fn start_with_optional_model_client(
mut launch: ResolvedStandaloneLaunch, launch: ResolvedStandaloneLaunch,
model_client: Option<Box<dyn LlmClient>>, model_client: Option<Box<dyn LlmClient>>,
) -> Result<Self, StandaloneStartupError> { ) -> Result<Self, StandaloneStartupError> {
let store = StandaloneSessionStore::open(&launch.state_dir) let store =
.map_err(classify_store_startup_error)?; StandaloneWorkerStore::open(&launch.state_dir).map_err(classify_store_startup_error)?;
let allocation = store let allocation = store
.allocate(&launch.cwd, StaleLeasePolicy::Reject) .allocate(&launch.cwd, StaleLeasePolicy::Reject)
.map_err(classify_store_startup_error)?; .map_err(classify_store_startup_error)?;
let id = allocation.id(); let worker_id = allocation.worker_id();
// The standalone session ID is the local identity. A unique internal Worker name avoids // WorkerId is the stable identity. The current Worker store remains
// process-global allocation collisions without creating a Runtime/Workspace Worker ID. // name-keyed, so keep its derived storage key separate from the
launch.profile.manifest.worker.name = format!("standalone-{id}"); // user-facing profile name.
let manifest = launch.profile.manifest.clone(); let manifest = launch.profile.manifest.clone();
let worker_name = manifest.worker.name.clone(); let storage_key = format!("standalone-{worker_id}");
let (backing_store, worker_store) = match backing_store(&store, id) { let mut bootstrap_manifest = manifest.clone();
bootstrap_manifest.worker.name = storage_key.clone();
let (backing_store, worker_store) = match backing_store(&store, worker_id) {
Ok(stores) => stores, Ok(stores) => stores,
Err(error) => { Err(error) => {
let _ = store.abandon_allocation(allocation); let _ = store.abandon_allocation(allocation);
@@ -112,10 +114,10 @@ impl StandaloneHost {
let filesystem_authority = let filesystem_authority =
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone()); WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
let runtime_base = store.runtime_dir(id); let runtime_base = store.runtime_dir(worker_id);
let mut bootstrap = WorkerBootstrap::new( let mut bootstrap = WorkerBootstrap::new(
manifest.clone(), bootstrap_manifest,
backing_store, backing_store,
launch.prompt_catalog, launch.prompt_catalog,
workspace_context, workspace_context,
@@ -133,7 +135,7 @@ impl StandaloneHost {
return Err(classify_startup_error(error)); return Err(classify_startup_error(error));
} }
}; };
let active = match active_pointer(&worker_store, &worker_name) { let active = match active_pointer(&worker_store, &storage_key) {
Ok(active) => active, Ok(active) => active,
Err(error) => { Err(error) => {
stop_started_worker(started).await; stop_started_worker(started).await;
@@ -141,16 +143,20 @@ impl StandaloneHost {
return Err(error); return Err(error);
} }
}; };
let record = let record = match store.commit_created(
match store.commit_created(&allocation, manifest, active.session_id, active.segment_id) &allocation,
{ manifest,
Ok(record) => record, storage_key,
Err(_) => { active.session_id,
stop_started_worker(started).await; active.segment_id,
let _ = store.abandon_allocation(allocation); ) {
return Err(StandaloneStartupError::StateStore); Ok(record) => record,
} Err(_) => {
}; stop_started_worker(started).await;
let _ = store.abandon_allocation(allocation);
return Err(StandaloneStartupError::StateStore);
}
};
Ok(Self::from_started( Ok(Self::from_started(
started, started,
store, store,
@@ -162,50 +168,46 @@ impl StandaloneHost {
pub async fn restore( pub async fn restore(
state_dir: PathBuf, state_dir: PathBuf,
session_id: StandaloneSessionId, worker_id: WorkerId,
) -> Result<Self, StandaloneStartupError> { ) -> Result<Self, StandaloneStartupError> {
Self::restore_with_optional_model_client(state_dir, session_id, None).await Self::restore_with_optional_model_client(state_dir, worker_id, None).await
} }
pub async fn restore_with_model_client<C>( pub async fn restore_with_model_client<C>(
state_dir: PathBuf, state_dir: PathBuf,
session_id: StandaloneSessionId, worker_id: WorkerId,
model_client: C, model_client: C,
) -> Result<Self, StandaloneStartupError> ) -> Result<Self, StandaloneStartupError>
where where
C: LlmClient + 'static, C: LlmClient + 'static,
{ {
Self::restore_with_optional_model_client( Self::restore_with_optional_model_client(state_dir, worker_id, Some(Box::new(model_client)))
state_dir, .await
session_id,
Some(Box::new(model_client)),
)
.await
} }
async fn restore_with_optional_model_client( async fn restore_with_optional_model_client(
state_dir: PathBuf, state_dir: PathBuf,
session_id: StandaloneSessionId, worker_id: WorkerId,
model_client: Option<Box<dyn LlmClient>>, model_client: Option<Box<dyn LlmClient>>,
) -> Result<Self, StandaloneStartupError> { ) -> Result<Self, StandaloneStartupError> {
let store = let store = StandaloneWorkerStore::open(state_dir).map_err(classify_store_startup_error)?;
StandaloneSessionStore::open(state_dir).map_err(classify_store_startup_error)?;
let record = store let record = store
.load(session_id) .load(worker_id)
.map_err(classify_store_startup_error)?; .map_err(classify_store_startup_error)?;
record.cwd.verify().map_err(classify_store_startup_error)?; record.cwd.verify().map_err(classify_store_startup_error)?;
let lease = store let lease = store
.acquire_lease(session_id, StaleLeasePolicy::Recover) .acquire_lease(worker_id, StaleLeasePolicy::Recover)
.map_err(classify_store_startup_error)?; .map_err(classify_store_startup_error)?;
let (backing_store, worker_store) = backing_store(&store, session_id)?; let (backing_store, worker_store) = backing_store(&store, worker_id)?;
let worker_name = record.worker_name.clone(); let storage_key = record.storage_key.clone();
let manifest = record.manifest.clone(); let mut manifest = record.manifest.clone();
manifest.worker.name = storage_key.clone();
let filesystem_authority = WorkerFilesystemAuthority::local( let filesystem_authority = WorkerFilesystemAuthority::local(
record.cwd.canonical_path.clone(), record.cwd.canonical_path.clone(),
record.cwd.canonical_path.clone(), record.cwd.canonical_path.clone(),
); );
let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
let runtime_base = store.runtime_dir(session_id); let runtime_base = store.runtime_dir(worker_id);
let mut bootstrap = WorkerBootstrap::new( let mut bootstrap = WorkerBootstrap::new(
manifest, manifest,
@@ -220,11 +222,11 @@ impl StandaloneHost {
bootstrap = bootstrap.with_model_client(model_client); bootstrap = bootstrap.with_model_client(model_client);
} }
let prepared = bootstrap let prepared = bootstrap
.prepare_restored(&worker_name) .prepare_restored(&storage_key)
.await .await
.map_err(classify_startup_error)?; .map_err(classify_startup_error)?;
let started = prepared.start().await.map_err(classify_startup_error)?; let started = prepared.start().await.map_err(classify_startup_error)?;
let active = match active_pointer(&worker_store, &worker_name) { let active = match active_pointer(&worker_store, &storage_key) {
Ok(active) => active, Ok(active) => active,
Err(error) => { Err(error) => {
stop_started_worker(started).await; stop_started_worker(started).await;
@@ -251,10 +253,10 @@ impl StandaloneHost {
fn from_started( fn from_started(
started: BootstrappedWorker, started: BootstrappedWorker,
store: StandaloneSessionStore, store: StandaloneWorkerStore,
worker_store: FsWorkerStore, worker_store: FsWorkerStore,
record: StandaloneSessionRecord, record: StandaloneWorkerRecord,
lease: StandaloneSessionLease, lease: StandaloneWorkerLease,
) -> Self { ) -> Self {
Self { Self {
handle: started.handle, handle: started.handle,
@@ -268,12 +270,12 @@ impl StandaloneHost {
} }
#[must_use] #[must_use]
pub fn session_id(&self) -> StandaloneSessionId { pub fn worker_id(&self) -> WorkerId {
self.record.session_id self.record.worker_id
} }
#[must_use] #[must_use]
pub fn record(&self) -> &StandaloneSessionRecord { pub fn record(&self) -> &StandaloneWorkerRecord {
&self.record &self.record
} }
@@ -310,7 +312,7 @@ impl StandaloneHost {
return Err(StandaloneShutdownError::DeadlineExceeded); return Err(StandaloneShutdownError::DeadlineExceeded);
} }
} }
let active = match active_pointer(&self.worker_store, &self.record.worker_name) { let active = match active_pointer(&self.worker_store, &self.record.storage_key) {
Ok(active) => active, Ok(active) => active,
Err(_) => { Err(_) => {
self.retain_lease(); self.retain_lease();
@@ -451,12 +453,12 @@ async fn send_protocol_event(peer: &InProcessPeer, event: Event) -> bool {
} }
fn backing_store( fn backing_store(
store: &StandaloneSessionStore, store: &StandaloneWorkerStore,
id: StandaloneSessionId, worker_id: WorkerId,
) -> Result<(StandaloneBackingStore, FsWorkerStore), StandaloneStartupError> { ) -> Result<(StandaloneBackingStore, FsWorkerStore), StandaloneStartupError> {
let session_store = let session_store = FsStore::new(store.sessions_dir(worker_id))
FsStore::new(store.session_log_dir(id)).map_err(|_| StandaloneStartupError::StateStore)?; .map_err(|_| StandaloneStartupError::StateStore)?;
let worker_store = FsWorkerStore::new(store.worker_metadata_dir(id)) let worker_store = FsWorkerStore::new(store.worker_metadata_dir(worker_id))
.map_err(|_| StandaloneStartupError::StateStore)?; .map_err(|_| StandaloneStartupError::StateStore)?;
Ok(( Ok((
CombinedStore::new(session_store, worker_store.clone()), CombinedStore::new(session_store, worker_store.clone()),
@@ -466,10 +468,10 @@ fn backing_store(
fn active_pointer( fn active_pointer(
worker_store: &FsWorkerStore, worker_store: &FsWorkerStore,
worker_name: &str, storage_key: &str,
) -> Result<WorkerActiveSegmentRef, StandaloneStartupError> { ) -> Result<WorkerActiveSegmentRef, StandaloneStartupError> {
worker_store worker_store
.read_by_name(worker_name) .read_by_name(storage_key)
.map_err(|_| StandaloneStartupError::StateStore)? .map_err(|_| StandaloneStartupError::StateStore)?
.and_then(|metadata| metadata.active) .and_then(|metadata| metadata.active)
.ok_or(StandaloneStartupError::StateStore) .ok_or(StandaloneStartupError::StateStore)
@@ -482,7 +484,7 @@ async fn stop_started_worker(started: BootstrappedWorker) {
fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartupError { fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartupError {
match error { match error {
StandaloneStoreError::SessionLeased(_) => StandaloneStartupError::SessionActive, StandaloneStoreError::WorkerLeased(_) => StandaloneStartupError::WorkerActive,
StandaloneStoreError::LeaseLivenessUnknown(_) => { StandaloneStoreError::LeaseLivenessUnknown(_) => {
StandaloneStartupError::LeaseLivenessUnknown StandaloneStartupError::LeaseLivenessUnknown
} }
+3 -3
View File
@@ -10,8 +10,8 @@ pub mod store;
pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError}; pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError};
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError}; pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
pub use protocol::WorkerId;
pub use store::{ pub use store::{
StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneSessionId, StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneShutdownReason,
StandaloneSessionRecord, StandaloneSessionStatus, StandaloneSessionStore, StandaloneStoreError, StandaloneWorkerRecord, StandaloneWorkerStatus, StandaloneWorkerStore,
StandaloneShutdownReason, StandaloneStoreError,
}; };
+106 -143
View File
@@ -1,12 +1,11 @@
use std::fmt;
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use fs4::fs_std::FileExt; use fs4::fs_std::FileExt;
use manifest::WorkerManifest; use manifest::WorkerManifest;
use protocol::WorkerId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::{SegmentId, SessionId}; use session_store::{SegmentId, SessionId};
use thiserror::Error; use thiserror::Error;
@@ -16,47 +15,10 @@ const RECORD_FILE: &str = "record.json";
const COMMIT_MARKER: &str = "commit.pending"; const COMMIT_MARKER: &str = "commit.pending";
const LEASE_FILE: &str = "lease.json"; const LEASE_FILE: &str = "lease.json";
const LEASE_LOCK_FILE: &str = "lease.lock"; const LEASE_LOCK_FILE: &str = "lease.lock";
const SESSION_DIR: &str = "session"; const SESSIONS_DIR: &str = "sessions";
const WORKER_DIR: &str = "worker"; const WORKER_DIR: &str = "worker";
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct StandaloneSessionId(Uuid);
impl StandaloneSessionId {
#[must_use]
pub fn new() -> Self {
Self(Uuid::now_v7())
}
#[must_use]
pub fn short(self) -> String {
let simple = self.0.simple().to_string();
simple[simple.len() - 12..].to_string()
}
}
impl Default for StandaloneSessionId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for StandaloneSessionId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
impl FromStr for StandaloneSessionId {
type Err = uuid::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Uuid::parse_str(value).map(Self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StandaloneCwdIdentity { pub struct StandaloneCwdIdentity {
pub canonical_path: PathBuf, pub canonical_path: PathBuf,
@@ -100,7 +62,7 @@ impl StandaloneCwdIdentity {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum StandaloneSessionStatus { pub enum StandaloneWorkerStatus {
Active, Active,
Stopped, Stopped,
} }
@@ -115,17 +77,20 @@ pub enum StandaloneShutdownReason {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandaloneSessionRecord { pub struct StandaloneWorkerRecord {
pub schema_version: u32, pub schema_version: u32,
pub revision: u64, pub revision: u64,
pub session_id: StandaloneSessionId, pub worker_id: WorkerId,
/// User-facing Worker name resolved from the profile.
pub worker_name: String, pub worker_name: String,
/// Internal key used by the current name-keyed Worker store.
pub storage_key: String,
pub cwd: StandaloneCwdIdentity, pub cwd: StandaloneCwdIdentity,
pub manifest: WorkerManifest, pub manifest: WorkerManifest,
pub active_session_id: SessionId, pub active_session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub active_segment_id: Option<SegmentId>, pub active_segment_id: Option<SegmentId>,
pub status: StandaloneSessionStatus, pub status: StandaloneWorkerStatus,
pub created_at_unix_ms: u64, pub created_at_unix_ms: u64,
pub updated_at_unix_ms: u64, pub updated_at_unix_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -145,11 +110,11 @@ pub enum StaleLeasePolicy {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StandaloneSessionStore { pub struct StandaloneWorkerStore {
root: PathBuf, root: PathBuf,
} }
impl StandaloneSessionStore { impl StandaloneWorkerStore {
pub fn open(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> { pub fn open(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> {
let root = root.into(); let root = root.into();
fs::create_dir_all(&root).map_err(StandaloneStoreError::Io)?; fs::create_dir_all(&root).map_err(StandaloneStoreError::Io)?;
@@ -171,35 +136,41 @@ impl StandaloneSessionStore {
&self, &self,
cwd: impl AsRef<Path>, cwd: impl AsRef<Path>,
policy: StaleLeasePolicy, policy: StaleLeasePolicy,
) -> Result<StandaloneSessionAllocation, StandaloneStoreError> { ) -> Result<StandaloneWorkerAllocation, StandaloneStoreError> {
let id = StandaloneSessionId::new(); let worker_id = WorkerId::now_v7();
let cwd = StandaloneCwdIdentity::capture(cwd)?; let cwd = StandaloneCwdIdentity::capture(cwd)?;
let dir = self.session_dir(id); let dir = self.worker_dir(worker_id);
fs::create_dir(&dir).map_err(StandaloneStoreError::Io)?; fs::create_dir(&dir).map_err(StandaloneStoreError::Io)?;
fs::create_dir(dir.join(SESSION_DIR)).map_err(StandaloneStoreError::Io)?; fs::create_dir(dir.join(SESSIONS_DIR)).map_err(StandaloneStoreError::Io)?;
fs::create_dir(dir.join(WORKER_DIR)).map_err(StandaloneStoreError::Io)?; fs::create_dir(dir.join(WORKER_DIR)).map_err(StandaloneStoreError::Io)?;
let lease = self.acquire_lease(id, policy)?; let lease = self.acquire_lease(worker_id, policy)?;
Ok(StandaloneSessionAllocation { id, cwd, lease }) Ok(StandaloneWorkerAllocation {
worker_id,
cwd,
lease,
})
} }
pub fn commit_created( pub fn commit_created(
&self, &self,
allocation: &StandaloneSessionAllocation, allocation: &StandaloneWorkerAllocation,
manifest: WorkerManifest, manifest: WorkerManifest,
storage_key: String,
active_session_id: SessionId, active_session_id: SessionId,
active_segment_id: Option<SegmentId>, active_segment_id: Option<SegmentId>,
) -> Result<StandaloneSessionRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let now = now_unix_ms()?; let now = now_unix_ms()?;
let record = StandaloneSessionRecord { let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
revision: 1, revision: 1,
session_id: allocation.id, worker_id: allocation.worker_id,
worker_name: manifest.worker.name.clone(), worker_name: manifest.worker.name.clone(),
storage_key,
cwd: allocation.cwd.clone(), cwd: allocation.cwd.clone(),
manifest, manifest,
active_session_id, active_session_id,
active_segment_id, active_segment_id,
status: StandaloneSessionStatus::Active, status: StandaloneWorkerStatus::Active,
created_at_unix_ms: now, created_at_unix_ms: now,
updated_at_unix_ms: now, updated_at_unix_ms: now,
shutdown_reason: None, shutdown_reason: None,
@@ -208,22 +179,19 @@ impl StandaloneSessionStore {
Ok(record) Ok(record)
} }
pub fn load( pub fn load(&self, id: WorkerId) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
&self, let dir = self.worker_dir(id);
id: StandaloneSessionId,
) -> Result<StandaloneSessionRecord, StandaloneStoreError> {
let dir = self.session_dir(id);
if dir.join(COMMIT_MARKER).exists() { if dir.join(COMMIT_MARKER).exists() {
return Err(StandaloneStoreError::IncompleteCommit(id)); return Err(StandaloneStoreError::IncompleteCommit(id));
} }
let bytes = fs::read(dir.join(RECORD_FILE)).map_err(|error| { let bytes = fs::read(dir.join(RECORD_FILE)).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound { if error.kind() == io::ErrorKind::NotFound {
StandaloneStoreError::SessionNotFound(id) StandaloneStoreError::WorkerNotFound(id)
} else { } else {
StandaloneStoreError::Io(error) StandaloneStoreError::Io(error)
} }
})?; })?;
let record: StandaloneSessionRecord = serde_json::from_slice(&bytes) let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?; .map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
if record.schema_version > SCHEMA_VERSION { if record.schema_version > SCHEMA_VERSION {
return Err(StandaloneStoreError::NewerSchema { return Err(StandaloneStoreError::NewerSchema {
@@ -232,7 +200,7 @@ impl StandaloneSessionStore {
supported: SCHEMA_VERSION, supported: SCHEMA_VERSION,
}); });
} }
if record.schema_version != SCHEMA_VERSION || record.session_id != id { if record.schema_version != SCHEMA_VERSION || record.worker_id != id {
return Err(StandaloneStoreError::InvalidRecord(id)); return Err(StandaloneStoreError::InvalidRecord(id));
} }
Ok(record) Ok(record)
@@ -243,7 +211,7 @@ impl StandaloneSessionStore {
cwd: impl AsRef<Path>, cwd: impl AsRef<Path>,
scope: StandaloneListScope, scope: StandaloneListScope,
limit: usize, limit: usize,
) -> Result<Vec<StandaloneSessionRecord>, StandaloneStoreError> { ) -> Result<Vec<StandaloneWorkerRecord>, StandaloneStoreError> {
let current_cwd = (scope == StandaloneListScope::CurrentCwd) let current_cwd = (scope == StandaloneListScope::CurrentCwd)
.then(|| StandaloneCwdIdentity::capture(cwd)) .then(|| StandaloneCwdIdentity::capture(cwd))
.transpose()?; .transpose()?;
@@ -269,12 +237,7 @@ impl StandaloneSessionStore {
right right
.updated_at_unix_ms .updated_at_unix_ms
.cmp(&left.updated_at_unix_ms) .cmp(&left.updated_at_unix_ms)
.then_with(|| { .then_with(|| right.worker_id.to_string().cmp(&left.worker_id.to_string()))
right
.session_id
.to_string()
.cmp(&left.session_id.to_string())
})
}); });
records.truncate(limit); records.truncate(limit);
Ok(records) Ok(records)
@@ -282,10 +245,10 @@ impl StandaloneSessionStore {
pub fn acquire_lease( pub fn acquire_lease(
&self, &self,
id: StandaloneSessionId, id: WorkerId,
policy: StaleLeasePolicy, policy: StaleLeasePolicy,
) -> Result<StandaloneSessionLease, StandaloneStoreError> { ) -> Result<StandaloneWorkerLease, StandaloneStoreError> {
let dir = self.session_dir(id); let dir = self.worker_dir(id);
let path = dir.join(LEASE_FILE); let path = dir.join(LEASE_FILE);
let _guard = LeaseMutationGuard::acquire(&dir)?; let _guard = LeaseMutationGuard::acquire(&dir)?;
let lease = LeaseRecord::current()?; let lease = LeaseRecord::current()?;
@@ -296,7 +259,7 @@ impl StandaloneSessionStore {
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?; file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
file.sync_all().map_err(StandaloneStoreError::Io)?; file.sync_all().map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?; sync_directory(&dir)?;
return Ok(StandaloneSessionLease { return Ok(StandaloneWorkerLease {
path, path,
lease_id: lease.lease_id, lease_id: lease.lease_id,
released: false, released: false,
@@ -306,7 +269,7 @@ impl StandaloneSessionStore {
let existing = read_lease(&path, id)?; let existing = read_lease(&path, id)?;
match existing.liveness() { match existing.liveness() {
LeaseLiveness::Live => { LeaseLiveness::Live => {
return Err(StandaloneStoreError::SessionLeased(id)); return Err(StandaloneStoreError::WorkerLeased(id));
} }
LeaseLiveness::Unknown => { LeaseLiveness::Unknown => {
return Err(StandaloneStoreError::LeaseLivenessUnknown(id)); return Err(StandaloneStoreError::LeaseLivenessUnknown(id));
@@ -326,16 +289,16 @@ impl StandaloneSessionStore {
pub fn update_active_pointer( pub fn update_active_pointer(
&self, &self,
record: &StandaloneSessionRecord, record: &StandaloneWorkerRecord,
active_session_id: SessionId, active_session_id: SessionId,
active_segment_id: Option<SegmentId>, active_segment_id: Option<SegmentId>,
) -> Result<StandaloneSessionRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone(); let mut next = record.clone();
next.revision = next.revision.saturating_add(1); next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?; next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id; next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id; next.active_segment_id = active_segment_id;
next.status = StandaloneSessionStatus::Active; next.status = StandaloneWorkerStatus::Active;
next.shutdown_reason = None; next.shutdown_reason = None;
self.commit_record(Some(record.revision), &next)?; self.commit_record(Some(record.revision), &next)?;
Ok(next) Ok(next)
@@ -343,73 +306,73 @@ impl StandaloneSessionStore {
pub fn mark_stopped( pub fn mark_stopped(
&self, &self,
record: &StandaloneSessionRecord, record: &StandaloneWorkerRecord,
active_session_id: SessionId, active_session_id: SessionId,
active_segment_id: Option<SegmentId>, active_segment_id: Option<SegmentId>,
reason: StandaloneShutdownReason, reason: StandaloneShutdownReason,
) -> Result<StandaloneSessionRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone(); let mut next = record.clone();
next.revision = next.revision.saturating_add(1); next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?; next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id; next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id; next.active_segment_id = active_segment_id;
next.status = StandaloneSessionStatus::Stopped; next.status = StandaloneWorkerStatus::Stopped;
next.shutdown_reason = Some(reason); next.shutdown_reason = Some(reason);
self.commit_record(Some(record.revision), &next)?; self.commit_record(Some(record.revision), &next)?;
Ok(next) Ok(next)
} }
pub fn delete(&self, id: StandaloneSessionId) -> Result<(), StandaloneStoreError> { pub fn delete(&self, id: WorkerId) -> Result<(), StandaloneStoreError> {
let record = self.load(id)?; let record = self.load(id)?;
if record.status != StandaloneSessionStatus::Stopped { if record.status != StandaloneWorkerStatus::Stopped {
return Err(StandaloneStoreError::DeleteActive(id)); return Err(StandaloneStoreError::DeleteActive(id));
} }
let session_dir = self.session_dir(id); let worker_dir = self.worker_dir(id);
let _guard = LeaseMutationGuard::acquire(&session_dir)?; let _guard = LeaseMutationGuard::acquire(&worker_dir)?;
let lease_path = session_dir.join(LEASE_FILE); let lease_path = worker_dir.join(LEASE_FILE);
if lease_path.exists() { if lease_path.exists() {
let lease = read_lease(&lease_path, id)?; let lease = read_lease(&lease_path, id)?;
return Err(match lease.liveness() { return Err(match lease.liveness() {
LeaseLiveness::Live => StandaloneStoreError::SessionLeased(id), LeaseLiveness::Live => StandaloneStoreError::WorkerLeased(id),
LeaseLiveness::Stale => StandaloneStoreError::StaleLease(id), LeaseLiveness::Stale => StandaloneStoreError::StaleLease(id),
LeaseLiveness::Unknown => StandaloneStoreError::LeaseLivenessUnknown(id), LeaseLiveness::Unknown => StandaloneStoreError::LeaseLivenessUnknown(id),
}); });
} }
fs::remove_dir_all(self.session_dir(id)).map_err(StandaloneStoreError::Io)?; fs::remove_dir_all(self.worker_dir(id)).map_err(StandaloneStoreError::Io)?;
sync_directory(&self.root) sync_directory(&self.root)
} }
#[must_use] #[must_use]
pub fn session_log_dir(&self, id: StandaloneSessionId) -> PathBuf { pub fn sessions_dir(&self, id: WorkerId) -> PathBuf {
self.session_dir(id).join(SESSION_DIR) self.worker_dir(id).join(SESSIONS_DIR)
} }
#[must_use] #[must_use]
pub fn worker_metadata_dir(&self, id: StandaloneSessionId) -> PathBuf { pub fn worker_metadata_dir(&self, id: WorkerId) -> PathBuf {
self.session_dir(id).join(WORKER_DIR) self.worker_dir(id).join(WORKER_DIR)
} }
#[must_use] #[must_use]
pub(crate) fn runtime_dir(&self, id: StandaloneSessionId) -> PathBuf { pub(crate) fn runtime_dir(&self, id: WorkerId) -> PathBuf {
self.session_dir(id).join("runtime") self.worker_dir(id).join("runtime")
} }
pub(crate) fn abandon_allocation( pub(crate) fn abandon_allocation(
&self, &self,
allocation: StandaloneSessionAllocation, allocation: StandaloneWorkerAllocation,
) -> Result<(), StandaloneStoreError> { ) -> Result<(), StandaloneStoreError> {
let id = allocation.id; let worker_id = allocation.worker_id;
allocation.lease.release()?; allocation.lease.release()?;
fs::remove_dir_all(self.session_dir(id)).map_err(StandaloneStoreError::Io)?; fs::remove_dir_all(self.worker_dir(worker_id)).map_err(StandaloneStoreError::Io)?;
sync_directory(&self.root) sync_directory(&self.root)
} }
fn commit_record( fn commit_record(
&self, &self,
expected_revision: Option<u64>, expected_revision: Option<u64>,
next: &StandaloneSessionRecord, next: &StandaloneWorkerRecord,
) -> Result<(), StandaloneStoreError> { ) -> Result<(), StandaloneStoreError> {
let dir = self.session_dir(next.session_id); let dir = self.worker_dir(next.worker_id);
let marker = dir.join(COMMIT_MARKER); let marker = dir.join(COMMIT_MARKER);
let mut marker_file = OpenOptions::new() let mut marker_file = OpenOptions::new()
.write(true) .write(true)
@@ -417,7 +380,7 @@ impl StandaloneSessionStore {
.open(&marker) .open(&marker)
.map_err(|error| { .map_err(|error| {
if error.kind() == io::ErrorKind::AlreadyExists { if error.kind() == io::ErrorKind::AlreadyExists {
StandaloneStoreError::IncompleteCommit(next.session_id) StandaloneStoreError::IncompleteCommit(next.worker_id)
} else { } else {
StandaloneStoreError::Io(error) StandaloneStoreError::Io(error)
} }
@@ -427,11 +390,11 @@ impl StandaloneSessionStore {
sync_directory(&dir)?; sync_directory(&dir)?;
if let Some(expected) = expected_revision { if let Some(expected) = expected_revision {
let current = self.load_record_while_committing(next.session_id)?; let current = self.load_record_while_committing(next.worker_id)?;
if current.revision != expected { if current.revision != expected {
let _ = fs::remove_file(&marker); let _ = fs::remove_file(&marker);
return Err(StandaloneStoreError::RevisionConflict { return Err(StandaloneStoreError::RevisionConflict {
id: next.session_id, id: next.worker_id,
expected, expected,
found: current.revision, found: current.revision,
}); });
@@ -461,30 +424,30 @@ impl StandaloneSessionStore {
fn load_record_while_committing( fn load_record_while_committing(
&self, &self,
id: StandaloneSessionId, id: WorkerId,
) -> Result<StandaloneSessionRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let bytes = let bytes =
fs::read(self.session_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?; fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
serde_json::from_slice(&bytes) serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source }) .map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
} }
fn session_dir(&self, id: StandaloneSessionId) -> PathBuf { fn worker_dir(&self, id: WorkerId) -> PathBuf {
self.root.join(id.to_string()) self.root.join(id.to_string())
} }
} }
#[derive(Debug)] #[derive(Debug)]
pub struct StandaloneSessionAllocation { pub struct StandaloneWorkerAllocation {
id: StandaloneSessionId, worker_id: WorkerId,
cwd: StandaloneCwdIdentity, cwd: StandaloneCwdIdentity,
lease: StandaloneSessionLease, lease: StandaloneWorkerLease,
} }
impl StandaloneSessionAllocation { impl StandaloneWorkerAllocation {
#[must_use] #[must_use]
pub fn id(&self) -> StandaloneSessionId { pub fn worker_id(&self) -> WorkerId {
self.id self.worker_id
} }
#[must_use] #[must_use]
@@ -492,19 +455,19 @@ impl StandaloneSessionAllocation {
&self.cwd &self.cwd
} }
pub fn into_lease(self) -> StandaloneSessionLease { pub fn into_lease(self) -> StandaloneWorkerLease {
self.lease self.lease
} }
} }
#[derive(Debug)] #[derive(Debug)]
pub struct StandaloneSessionLease { pub struct StandaloneWorkerLease {
path: PathBuf, path: PathBuf,
lease_id: Uuid, lease_id: Uuid,
released: bool, released: bool,
} }
impl StandaloneSessionLease { impl StandaloneWorkerLease {
pub fn release(mut self) -> Result<(), StandaloneStoreError> { pub fn release(mut self) -> Result<(), StandaloneStoreError> {
self.release_inner() self.release_inner()
} }
@@ -534,7 +497,7 @@ impl StandaloneSessionLease {
} }
} }
impl Drop for StandaloneSessionLease { impl Drop for StandaloneWorkerLease {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.release_inner(); let _ = self.release_inner();
} }
@@ -624,7 +587,7 @@ fn classify_lease_liveness(
} }
} }
fn read_lease(path: &Path, id: StandaloneSessionId) -> Result<LeaseRecord, StandaloneStoreError> { fn read_lease(path: &Path, id: WorkerId) -> Result<LeaseRecord, StandaloneStoreError> {
let bytes = fs::read(path).map_err(StandaloneStoreError::Io)?; let bytes = fs::read(path).map_err(StandaloneStoreError::Io)?;
serde_json::from_slice(&bytes) serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptLease { id, source }) .map_err(|source| StandaloneStoreError::CorruptLease { id, source })
@@ -692,47 +655,47 @@ pub enum StandaloneStoreError {
CwdUnavailable(#[source] io::Error), CwdUnavailable(#[source] io::Error),
#[error("standalone cwd is not a directory")] #[error("standalone cwd is not a directory")]
CwdNotDirectory, CwdNotDirectory,
#[error("standalone cwd identity no longer matches the persisted session")] #[error("standalone cwd identity no longer matches the persisted Worker")]
CwdIdentityMismatch, CwdIdentityMismatch,
#[error("standalone session {0} was not found")] #[error("standalone Worker {0} was not found")]
SessionNotFound(StandaloneSessionId), WorkerNotFound(WorkerId),
#[error("standalone session {0} has an incomplete metadata commit")] #[error("standalone Worker {0} has an incomplete metadata commit")]
IncompleteCommit(StandaloneSessionId), IncompleteCommit(WorkerId),
#[error("standalone session {0} has invalid metadata")] #[error("standalone Worker {0} has invalid metadata")]
InvalidRecord(StandaloneSessionId), InvalidRecord(WorkerId),
#[error("standalone session {id} metadata is corrupt")] #[error("standalone Worker {id} metadata is corrupt")]
CorruptRecord { CorruptRecord {
id: StandaloneSessionId, id: WorkerId,
#[source] #[source]
source: serde_json::Error, source: serde_json::Error,
}, },
#[error("standalone session {id} lease is corrupt")] #[error("standalone Worker {id} lease is corrupt")]
CorruptLease { CorruptLease {
id: StandaloneSessionId, id: WorkerId,
#[source] #[source]
source: serde_json::Error, source: serde_json::Error,
}, },
#[error("standalone session {id} uses schema {found}, newer than supported schema {supported}")] #[error("standalone Worker {id} uses schema {found}, newer than supported schema {supported}")]
NewerSchema { NewerSchema {
id: StandaloneSessionId, id: WorkerId,
found: u32, found: u32,
supported: u32, supported: u32,
}, },
#[error("standalone session {0} is already active")] #[error("standalone Worker {0} is already active")]
SessionLeased(StandaloneSessionId), WorkerLeased(WorkerId),
#[error("standalone session {0} lease liveness cannot be proven; recovery is rejected")] #[error("standalone Worker {0} lease liveness cannot be proven; recovery is rejected")]
LeaseLivenessUnknown(StandaloneSessionId), LeaseLivenessUnknown(WorkerId),
#[error("standalone session {0} has a stale lease; explicit recovery is required")] #[error("standalone Worker {0} has a stale lease; explicit recovery is required")]
StaleLease(StandaloneSessionId), StaleLease(WorkerId),
#[error("standalone session lease ownership changed")] #[error("standalone Worker lease ownership changed")]
LeaseOwnershipLost, LeaseOwnershipLost,
#[error("standalone session {0} must be stopped before deletion")] #[error("standalone Worker {0} must be stopped before deletion")]
DeleteActive(StandaloneSessionId), DeleteActive(WorkerId),
#[error( #[error(
"standalone session {id} metadata revision changed (expected {expected}, found {found})" "standalone Worker {id} metadata revision changed (expected {expected}, found {found})"
)] )]
RevisionConflict { RevisionConflict {
id: StandaloneSessionId, id: WorkerId,
expected: u64, expected: u64,
found: u64, found: u64,
}, },
+51 -43
View File
@@ -14,7 +14,7 @@ use futures::{Stream, stream};
use protocol::{Event, Method}; use protocol::{Event, Method};
use standalone::{ use standalone::{
StaleLeasePolicy, StandaloneHost, StandaloneLaunchConfig, StandaloneListScope, StaleLeasePolicy, StandaloneHost, StandaloneLaunchConfig, StandaloneListScope,
StandaloneSessionStatus, StandaloneSessionStore, StandaloneStartupError, StandaloneStoreError, StandaloneStartupError, StandaloneStoreError, StandaloneWorkerStatus, StandaloneWorkerStore,
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -90,6 +90,12 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
let host = StandaloneHost::start_with_model_client(launch, client) let host = StandaloneHost::start_with_model_client(launch, client)
.await .await
.expect("start in-process host"); .expect("start in-process host");
assert_eq!(host.record().worker_name, worker_name);
assert_eq!(host.record().manifest.worker.name, worker_name);
assert_eq!(
host.record().storage_key,
format!("standalone-{}", host.worker_id())
);
let mut protocol_client = host.connect(); let mut protocol_client = host.connect();
protocol_client protocol_client
@@ -238,7 +244,7 @@ type TestResult = Result<(), Box<dyn std::error::Error>>;
async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope() -> TestResult { async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope() -> TestResult {
let temp = tempfile::tempdir()?; let temp = tempfile::tempdir()?;
let cwd = temp.path().join("project"); let cwd = temp.path().join("project");
let state_dir = temp.path().join("client").join("standalone-sessions"); let state_dir = temp.path().join("client").join("standalone-workers");
std::fs::create_dir_all(&cwd)?; std::fs::create_dir_all(&cwd)?;
let launch = StandaloneLaunchConfig::new( let launch = StandaloneLaunchConfig::new(
&cwd, &cwd,
@@ -268,7 +274,7 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
], ],
]); ]);
let host = StandaloneHost::start_with_model_client(launch, first_client).await?; let host = StandaloneHost::start_with_model_client(launch, first_client).await?;
let session_id = host.session_id(); let worker_id = host.worker_id();
let mut protocol_client = host.connect(); let mut protocol_client = host.connect();
protocol_client protocol_client
.send(&Method::run_text("first request")) .send(&Method::run_text("first request"))
@@ -283,11 +289,11 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
wait_for_run_end(&mut protocol_client).await?; wait_for_run_end(&mut protocol_client).await?;
host.shutdown().await?; host.shutdown().await?;
let store = StandaloneSessionStore::open(&state_dir)?; let store = StandaloneWorkerStore::open(&state_dir)?;
let current = store.list(&cwd, StandaloneListScope::CurrentCwd, 100)?; let current = store.list(&cwd, StandaloneListScope::CurrentCwd, 100)?;
assert_eq!(current.len(), 1); assert_eq!(current.len(), 1);
assert_eq!(current[0].session_id, session_id); assert_eq!(current[0].worker_id, worker_id);
assert_eq!(current[0].status, StandaloneSessionStatus::Stopped); assert_eq!(current[0].status, StandaloneWorkerStatus::Stopped);
let other_cwd = temp.path().join("other"); let other_cwd = temp.path().join("other");
std::fs::create_dir(&other_cwd)?; std::fs::create_dir(&other_cwd)?;
assert!( assert!(
@@ -307,8 +313,13 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
]]); ]]);
let second_inspection = second_client.clone(); let second_inspection = second_client.clone();
let host = let host =
StandaloneHost::restore_with_model_client(state_dir.clone(), session_id, second_client) StandaloneHost::restore_with_model_client(state_dir.clone(), worker_id, second_client)
.await?; .await?;
assert_eq!(
host.record().worker_name,
"display-name-is-not-session-identity"
);
assert_eq!(host.record().storage_key, format!("standalone-{worker_id}"));
let mut protocol_client = host.connect(); let mut protocol_client = host.connect();
let snapshot = format!( let snapshot = format!(
"{:?}", "{:?}",
@@ -338,11 +349,11 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
assert!(projected.contains("persisted task"), "{projected}"); assert!(projected.contains("persisted task"), "{projected}");
host.shutdown().await?; host.shutdown().await?;
store.delete(session_id)?; store.delete(worker_id)?;
assert!(cwd.exists(), "deleting session state must not mutate cwd"); assert!(cwd.exists(), "deleting session state must not mutate cwd");
assert!(matches!( assert!(matches!(
store.load(session_id), store.load(worker_id),
Err(StandaloneStoreError::SessionNotFound(_)) Err(StandaloneStoreError::WorkerNotFound(_))
)); ));
Ok(()) Ok(())
} }
@@ -363,28 +374,25 @@ async fn standalone_restore_rejects_concurrent_lease_and_missing_cwd() -> TestRe
.resolve()?; .resolve()?;
let host = let host =
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?; StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
let session_id = host.session_id(); let worker_id = host.worker_id();
let store = StandaloneSessionStore::open(&state_dir)?; let store = StandaloneWorkerStore::open(&state_dir)?;
assert!(matches!( assert!(matches!(
store.acquire_lease(session_id, StaleLeasePolicy::Recover), store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
Err(StandaloneStoreError::SessionLeased(id)) if id == session_id Err(StandaloneStoreError::WorkerLeased(id)) if id == worker_id
)); ));
let restore = StandaloneHost::restore_with_model_client( let restore = StandaloneHost::restore_with_model_client(
state_dir.clone(), state_dir.clone(),
session_id, worker_id,
ScriptedClient::new(Vec::new()), ScriptedClient::new(Vec::new()),
) )
.await; .await;
assert!(matches!( assert!(matches!(restore, Err(StandaloneStartupError::WorkerActive)));
restore,
Err(StandaloneStartupError::SessionActive)
));
host.shutdown().await?; host.shutdown().await?;
std::fs::rename(&cwd, &moved)?; std::fs::rename(&cwd, &moved)?;
let restore = StandaloneHost::restore_with_model_client( let restore = StandaloneHost::restore_with_model_client(
state_dir, state_dir,
session_id, worker_id,
ScriptedClient::new(Vec::new()), ScriptedClient::new(Vec::new()),
) )
.await; .await;
@@ -421,11 +429,11 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
}); });
let host = let host =
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?; StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
let session_id = host.session_id(); let worker_id = host.worker_id();
host.shutdown().await?; host.shutdown().await?;
let store = StandaloneSessionStore::open(&state_dir)?; let store = StandaloneWorkerStore::open(&state_dir)?;
assert!(matches!( assert!(matches!(
store.load(session_id)?.manifest.profile, store.load(worker_id)?.manifest.profile,
Some(manifest::ProfileManifestSnapshot { Some(manifest::ProfileManifestSnapshot {
source: manifest::ProfileSource::Registry { source: manifest::ProfileSource::Registry {
source: manifest::ProfileRegistrySource::User, source: manifest::ProfileRegistrySource::User,
@@ -434,9 +442,9 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
.. ..
}) })
)); ));
let session_dir = state_dir.join(session_id.to_string()); let worker_dir = state_dir.join(worker_id.to_string());
std::fs::write( std::fs::write(
session_dir.join("lease.json"), worker_dir.join("lease.json"),
serde_json::to_vec(&serde_json::json!({ serde_json::to_vec(&serde_json::json!({
"lease_id": uuid::Uuid::now_v7(), "lease_id": uuid::Uuid::now_v7(),
"pid": u32::MAX, "pid": u32::MAX,
@@ -447,7 +455,7 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
let host = StandaloneHost::restore_with_model_client( let host = StandaloneHost::restore_with_model_client(
state_dir, state_dir,
session_id, worker_id,
ScriptedClient::new(Vec::new()), ScriptedClient::new(Vec::new()),
) )
.await?; .await?;
@@ -468,11 +476,11 @@ async fn standalone_restore_rejects_lease_with_missing_start_marker() -> TestRes
.resolve()?; .resolve()?;
let host = let host =
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?; StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
let session_id = host.session_id(); let worker_id = host.worker_id();
host.shutdown().await?; host.shutdown().await?;
let session_dir = state_dir.join(session_id.to_string()); let worker_dir = state_dir.join(worker_id.to_string());
std::fs::write( std::fs::write(
session_dir.join("lease.json"), worker_dir.join("lease.json"),
serde_json::to_vec(&serde_json::json!({ serde_json::to_vec(&serde_json::json!({
"lease_id": uuid::Uuid::now_v7(), "lease_id": uuid::Uuid::now_v7(),
"pid": std::process::id(), "pid": std::process::id(),
@@ -480,14 +488,14 @@ async fn standalone_restore_rejects_lease_with_missing_start_marker() -> TestRes
}))?, }))?,
)?; )?;
let store = StandaloneSessionStore::open(&state_dir)?; let store = StandaloneWorkerStore::open(&state_dir)?;
assert!(matches!( assert!(matches!(
store.acquire_lease(session_id, StaleLeasePolicy::Recover), store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
Err(StandaloneStoreError::LeaseLivenessUnknown(id)) if id == session_id Err(StandaloneStoreError::LeaseLivenessUnknown(id)) if id == worker_id
)); ));
let restore = StandaloneHost::restore_with_model_client( let restore = StandaloneHost::restore_with_model_client(
state_dir, state_dir,
session_id, worker_id,
ScriptedClient::new(Vec::new()), ScriptedClient::new(Vec::new()),
) )
.await; .await;
@@ -511,23 +519,23 @@ async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> Te
.resolve()?; .resolve()?;
let host = let host =
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?; StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
let session_id = host.session_id(); let worker_id = host.worker_id();
host.shutdown().await?; host.shutdown().await?;
let store = StandaloneSessionStore::open(&state_dir)?; let store = StandaloneWorkerStore::open(&state_dir)?;
let session_dir = state_dir.join(session_id.to_string()); let worker_dir = state_dir.join(worker_id.to_string());
std::fs::write(session_dir.join("commit.pending"), b"interrupted\n")?; std::fs::write(worker_dir.join("commit.pending"), b"interrupted\n")?;
assert!(matches!( assert!(matches!(
store.load(session_id), store.load(worker_id),
Err(StandaloneStoreError::IncompleteCommit(id)) if id == session_id Err(StandaloneStoreError::IncompleteCommit(id)) if id == worker_id
)); ));
std::fs::remove_file(session_dir.join("commit.pending"))?; std::fs::remove_file(worker_dir.join("commit.pending"))?;
let record_path = session_dir.join("record.json"); let record_path = worker_dir.join("record.json");
let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&record_path)?)?; let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&record_path)?)?;
record["schema_version"] = serde_json::json!(u32::MAX); record["schema_version"] = serde_json::json!(u32::MAX);
std::fs::write(&record_path, serde_json::to_vec_pretty(&record)?)?; std::fs::write(&record_path, serde_json::to_vec_pretty(&record)?)?;
assert!(matches!( assert!(matches!(
store.load(session_id), store.load(worker_id),
Err(StandaloneStoreError::NewerSchema { id, .. }) if id == session_id Err(StandaloneStoreError::NewerSchema { id, .. }) if id == worker_id
)); ));
Ok(()) Ok(())
} }
+6 -8
View File
@@ -25,9 +25,7 @@ use tokio::sync::mpsc;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use client::transport::Socket; use client::transport::Socket;
use client::{ use client::{BackendRuntimeTarget, Client, StandaloneWorkerResumeIntent, connect_backend_runtime};
BackendRuntimeTarget, Client, StandaloneSessionResumeIntent, connect_backend_runtime,
};
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::composer_keys::{ComposerEditAction, composer_edit_action}; use crate::composer_keys::{ComposerEditAction, composer_edit_action};
@@ -193,18 +191,18 @@ pub(crate) async fn run_standalone(
} }
pub(crate) async fn run_standalone_restore( pub(crate) async fn run_standalone_restore(
intent: StandaloneSessionResumeIntent, intent: StandaloneWorkerResumeIntent,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let session_id = intent.session_id.parse().map_err(|error| { let worker_id = intent.worker_id.parse().map_err(|error| {
io::Error::new( io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
format!("Invalid standalone session ID: {error}"), format!("Invalid standalone Worker ID: {error}"),
) )
})?; })?;
let host = StandaloneHost::restore(intent.state_dir, session_id) let host = StandaloneHost::restore(intent.state_dir, worker_id)
.await .await
.map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?; .map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?;
let worker_label = format!("standalone-{}", session_id.short()); let worker_label = host.record().worker_name.clone();
let history_root = host.record().cwd.canonical_path.clone(); let history_root = host.record().cwd.canonical_path.clone();
run_standalone_host(host, worker_label, history_root).await run_standalone_host(host, worker_label, history_root).await
} }
+2 -2
View File
@@ -46,8 +46,8 @@ pub enum LaunchMode {
worker_name: Option<String>, worker_name: Option<String>,
profile: Option<String>, profile: Option<String>,
}, },
/// Restore one client-owned standalone session. The current cwd is the default scope; /// Restore one client-owned standalone Worker. The current cwd is the default scope;
/// `include_all` opts into all standalone sessions under the same client data root. /// `include_all` opts into all standalone Workers under the same client data root.
StandaloneResume { include_all: bool }, StandaloneResume { include_all: bool },
/// List Backend Workers and attach to the selected Worker. /// List Backend Workers and attach to the selected Worker.
Workers { Workers {
+21 -18
View File
@@ -1,7 +1,7 @@
use std::io; use std::io;
use std::time::Duration; use std::time::Duration;
use client::{StandaloneSessionListIntent, StandaloneSessionResumeIntent, Target}; use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
@@ -9,7 +9,7 @@ use ratatui::layout::{Constraint, Layout};
use ratatui::prelude::{Color, Line, Modifier, Span, Style}; use ratatui::prelude::{Color, Line, Modifier, Span, Style};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::{TerminalOptions, Viewport}; use ratatui::{TerminalOptions, Viewport};
use standalone::{StandaloneListScope, StandaloneSessionRecord, StandaloneSessionStore}; use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore};
use thiserror::Error; use thiserror::Error;
const LIMIT: usize = 100; const LIMIT: usize = 100;
@@ -17,28 +17,28 @@ const LIMIT: usize = 100;
pub(crate) fn pick( pub(crate) fn pick(
target: &dyn Target, target: &dyn Target,
include_all: bool, include_all: bool,
) -> Result<Option<StandaloneSessionResumeIntent>, StandalonePickerError> { ) -> Result<Option<StandaloneWorkerResumeIntent>, StandalonePickerError> {
let intent = target let intent = target
.standalone_session_list(include_all) .standalone_worker_list(include_all)
.map_err(StandalonePickerError::Target)?; .map_err(StandalonePickerError::Target)?;
let records = load_records(&intent)?; let records = load_records(&intent)?;
if records.is_empty() { if records.is_empty() {
return Err(StandalonePickerError::NoSessions { include_all }); return Err(StandalonePickerError::NoWorkers { include_all });
} }
let selected = run_picker(records)?; let selected = run_picker(records)?;
selected selected
.map(|record| { .map(|record| {
target target
.standalone_session_resume(record.session_id.to_string()) .standalone_worker_resume(record.worker_id.to_string())
.map_err(StandalonePickerError::Target) .map_err(StandalonePickerError::Target)
}) })
.transpose() .transpose()
} }
fn load_records( fn load_records(
intent: &StandaloneSessionListIntent, intent: &StandaloneWorkerListIntent,
) -> Result<Vec<StandaloneSessionRecord>, StandalonePickerError> { ) -> Result<Vec<StandaloneWorkerRecord>, StandalonePickerError> {
let store = StandaloneSessionStore::open(&intent.state_dir) let store = StandaloneWorkerStore::open(&intent.state_dir)
.map_err(StandalonePickerError::StateStore)?; .map_err(StandalonePickerError::StateStore)?;
store store
.list( .list(
@@ -54,8 +54,8 @@ fn load_records(
} }
fn run_picker( fn run_picker(
records: Vec<StandaloneSessionRecord>, records: Vec<StandaloneWorkerRecord>,
) -> Result<Option<StandaloneSessionRecord>, StandalonePickerError> { ) -> Result<Option<StandaloneWorkerRecord>, StandalonePickerError> {
let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20); let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20);
let mut terminal = Terminal::with_options( let mut terminal = Terminal::with_options(
CrosstermBackend::new(io::stdout()), CrosstermBackend::new(io::stdout()),
@@ -94,14 +94,14 @@ fn run_picker(
} }
} }
fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneSessionRecord], selected: usize) { fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneWorkerRecord], selected: usize) {
let mut constraints = vec![Constraint::Length(1)]; let mut constraints = vec![Constraint::Length(1)];
constraints.extend(records.iter().map(|_| Constraint::Length(1))); constraints.extend(records.iter().map(|_| Constraint::Length(1)));
constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1));
let rows = Layout::vertical(constraints).split(frame.area()); let rows = Layout::vertical(constraints).split(frame.area());
frame.render_widget( frame.render_widget(
Paragraph::new(Line::from(Span::styled( Paragraph::new(Line::from(Span::styled(
"resume standalone session", "resume standalone Worker",
Style::default().add_modifier(Modifier::BOLD), Style::default().add_modifier(Modifier::BOLD),
))), ))),
rows[0], rows[0],
@@ -120,7 +120,10 @@ fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneSessionRecord], sel
frame.render_widget( frame.render_widget(
Paragraph::new(Line::from(vec![ Paragraph::new(Line::from(vec![
Span::raw(marker), Span::raw(marker),
Span::styled(record.session_id.short(), style), Span::styled(
format!("{} ({})", record.worker_name, record.worker_id.short()),
style,
),
Span::raw(format!( Span::raw(format!(
" [{:?}] updated:{} {}", " [{:?}] updated:{} {}",
record.status, record.updated_at_unix_ms, cwd record.status, record.updated_at_unix_ms, cwd
@@ -139,13 +142,13 @@ fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneSessionRecord], sel
pub(crate) enum StandalonePickerError { pub(crate) enum StandalonePickerError {
#[error("standalone target error: {0}")] #[error("standalone target error: {0}")]
Target(#[source] client::TargetError), Target(#[source] client::TargetError),
#[error("standalone session state is unavailable: {0}")] #[error("standalone Worker state is unavailable: {0}")]
StateStore(#[source] standalone::StandaloneStoreError), StateStore(#[source] standalone::StandaloneStoreError),
#[error( #[error(
"no standalone sessions found for this cwd; use `yoi --local --resume --all` to include all cwd identities" "no standalone Workers found for this cwd; use `yoi --local --resume --all` to include all cwd identities"
)] )]
NoSessions { include_all: bool }, NoWorkers { include_all: bool },
#[error("standalone session picker I/O failed: {0}")] #[error("standalone Worker picker I/O failed: {0}")]
Io(#[source] io::Error), Io(#[source] io::Error),
} }
+3 -107
View File
@@ -1,105 +1,9 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{fmt, str::FromStr};
use uuid::{Uuid, Version};
pub use protocol::{WorkerId, WorkerIdParseError};
pub use workdir::workspace::RuntimeWorkerRef; pub use workdir::workspace::RuntimeWorkerRef;
/// Stable Workspace-owned Worker identity.
///
/// Runtime placement is deliberately not part of this value. New identities are
/// allocated by Workspace authority before a Runtime create request is sent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct WorkerId(Uuid);
impl WorkerId {
pub fn now_v7() -> Self {
Self(Uuid::now_v7())
}
/// Converts a legacy Runtime-local numeric id into a syntactically valid
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
pub fn from_legacy_u64(value: u64) -> Self {
let mut bytes = [0_u8; 16];
bytes[8..].copy_from_slice(&value.to_be_bytes());
bytes[6] = 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
}
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
let mut hasher = Sha256::new();
hasher.update(b"yoi.workspace-worker-id.v1\0");
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(runtime_id.as_bytes());
hasher.update([0]);
hasher.update(value.to_be_bytes());
let digest = hasher.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
// Migrated ids sort before normally allocated UUIDv7 values while retaining
// deterministic collision-resistant payload bits.
bytes[..6].fill(0);
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
}
pub fn parse(value: &str) -> Option<Self> {
let value = Uuid::parse_str(value).ok()?;
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
}
pub const fn as_uuid(self) -> Uuid {
self.0
}
}
impl fmt::Display for WorkerId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
impl FromStr for WorkerId {
type Err = WorkerIdParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value).ok_or(WorkerIdParseError)
}
}
impl Serialize for WorkerId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for WorkerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkerIdParseError;
impl fmt::Display for WorkerIdParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("Worker id must be a UUIDv7")
}
}
impl std::error::Error for WorkerIdParseError {}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LegacyWorkerIdentityMapping { pub struct LegacyWorkerIdentityMapping {
pub workspace_id: String, pub workspace_id: String,
@@ -140,7 +44,7 @@ pub fn legacy_worker_identity_mapping_digest(mappings: &[LegacyWorkerIdentityMap
} }
/// Runtime-local authority reference for Worker operations. The contained id is /// Runtime-local authority reference for Worker operations. The contained id is
/// nevertheless the Workspace-owned stable identity; the Runtime does not mint it. /// nevertheless the stable Worker identity; the Runtime does not mint it.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct WorkerRef { pub struct WorkerRef {
pub worker_id: WorkerId, pub worker_id: WorkerId,
@@ -164,14 +68,6 @@ impl TryFrom<&RuntimeWorkerRef> for WorkerRef {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn worker_id_accepts_only_uuid_v7() {
let worker_id = WorkerId::now_v7();
assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
assert!(WorkerId::parse("30").is_none());
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
}
#[test] #[test]
fn runtime_worker_ref_preserves_stable_worker_identity() { fn runtime_worker_ref_preserves_stable_worker_identity() {
let worker_id = WorkerId::now_v7(); let worker_id = WorkerId::now_v7();
+1 -1
View File
@@ -118,7 +118,7 @@ fn standalone_target() -> Result<Box<dyn Target>, ParseError> {
})? })?
.join("client") .join("client")
.join("standalone") .join("standalone")
.join("sessions"); .join("workers");
Ok(Box::new(StandaloneTarget::new(state_dir))) Ok(Box::new(StandaloneTarget::new(state_dir)))
} }
+15 -15
View File
@@ -798,7 +798,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
} }
if session.is_some() { if session.is_some() {
return Err(ParseError( return Err(ParseError(
"--local does not accept legacy --session; use --local --resume for Standalone session restore" "--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
.to_string(), .to_string(),
)); ));
} }
@@ -834,7 +834,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
if target.kind() == TargetKind::Standalone && (session.is_some() || socket_override.is_some()) { if target.kind() == TargetKind::Standalone && (session.is_some() || socket_override.is_some()) {
return Err(ParseError( return Err(ParseError(
"Standalone does not accept legacy Worker session or socket selectors; use --resume for the standalone session store" "Standalone does not accept legacy Worker session or socket selectors; use --resume for the standalone Worker store"
.to_string(), .to_string(),
)); ));
} }
@@ -951,7 +951,7 @@ fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
)?; )?;
if target.kind() != TargetKind::Backend { if target.kind() != TargetKind::Backend {
return Err(ParseError( return Err(ParseError(
"yoi workers requires a Backend connection target; use yoi --local --resume for Standalone sessions" "yoi workers requires a Backend connection target; use yoi --local --resume for Standalone Workers"
.to_string(), .to_string(),
)); ));
} }
@@ -1750,8 +1750,8 @@ Usage:
Target selection: Target selection:
--local Use the client-owned one-process Standalone host --local Use the client-owned one-process Standalone host
--resume With --local, restore from the Standalone session store --resume With --local, restore from the Standalone Worker store
--all With Standalone restore, include sessions from every cwd identity --all With Standalone restore, include Workers from every cwd identity
--backend <URL> Use a Workspace Backend explicitly --backend <URL> Use a Workspace Backend explicitly
--workspace-id <ID> Scope Backend routes to a Workspace id --workspace-id <ID> Scope Backend routes to a Workspace id
@@ -1761,7 +1761,7 @@ Target selection:
Connection-aware commands: Connection-aware commands:
yoi Standalone: new Console. Backend: Worker picker. yoi Standalone: new Console. Backend: Worker picker.
yoi resume Standalone session picker or stopped Backend Worker picker. yoi resume Standalone Worker picker or stopped Backend Worker picker.
yoi workers Backend Workspace Worker picker. yoi workers Backend Workspace Worker picker.
yoi panel Backend Workspace dashboard. yoi panel Backend Workspace dashboard.
@@ -1800,7 +1800,7 @@ Usage:
yoi --backend <URL> [--workspace-id <ID>] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>] yoi --backend <URL> [--workspace-id <ID>] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]
Authority: Authority:
Lists Workers from the selected Backend Workspace. Standalone sessions are restored with Lists Workers from the selected Backend Workspace. Standalone Workers are restored with
`yoi --local --resume` and are not part of the Workspace Worker catalog. `yoi --local --resume` and are not part of the Workspace Worker catalog.
Options: Options:
@@ -1822,13 +1822,13 @@ Usage:
yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>] yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>]
Target options: Target options:
--local Restore from the client-owned Standalone session store --local Restore from the client-owned Standalone Worker store
--backend <URL> Restore a stopped Backend Workspace Worker --backend <URL> Restore a stopped Backend Workspace Worker
--workspace-id <ID> Scope Backend routes to a Workspace id --workspace-id <ID> Scope Backend routes to a Workspace id
Options: Options:
--workspace <PATH> Scope Standalone sessions to this cwd identity (defaults to cwd) --workspace <PATH> Scope Standalone Workers to this cwd identity (defaults to cwd)
--all Include Standalone sessions from every cwd identity --all Include Standalone Workers from every cwd identity
--runtime-id <ID> Restrict the Backend stopped-Worker picker to a Runtime id --runtime-id <ID> Restrict the Backend stopped-Worker picker to a Runtime id
-h, --help Print help -h, --help Print help
"#; "#;
@@ -2222,8 +2222,8 @@ backend = "shared"
mode, mode,
LaunchMode::StandaloneResume { include_all: false } LaunchMode::StandaloneResume { include_all: false }
)); ));
let intent = target.standalone_session_list(false).unwrap(); let intent = target.standalone_worker_list(false).unwrap();
assert!(intent.state_dir.ends_with("client/standalone/sessions")); assert!(intent.state_dir.ends_with("client/standalone/workers"));
assert!(!intent.include_all); assert!(!intent.include_all);
let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap(); let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap();
@@ -2264,7 +2264,7 @@ backend = "shared"
let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err(); let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err();
assert_eq!( assert_eq!(
err.0, err.0,
"--local does not accept legacy --session; use --local --resume for Standalone session restore" "--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
); );
let socket_args = [ let socket_args = [
@@ -2903,12 +2903,12 @@ backend = "shared"
} }
#[test] #[test]
fn parse_resume_help_uses_standalone_session_store_terminology() { fn parse_resume_help_uses_standalone_worker_store_terminology() {
match parse_args_from(["resume", "--help"]).unwrap() { match parse_args_from(["resume", "--help"]).unwrap() {
Mode::ResumeHelp => {} Mode::ResumeHelp => {}
_ => panic!("expected ResumeHelp mode"), _ => panic!("expected ResumeHelp mode"),
} }
assert!(RESUME_HELP.contains("Standalone session store")); assert!(RESUME_HELP.contains("Standalone Worker store"));
assert!(RESUME_HELP.contains("Backend stopped-Worker picker")); assert!(RESUME_HELP.contains("Backend stopped-Worker picker"));
assert!(!RESUME_HELP.contains("local Worker records")); assert!(!RESUME_HELP.contains("local Worker records"));
assert!(!RESUME_HELP.contains("local workspace")); assert!(!RESUME_HELP.contains("local workspace"));