From e077cdc8e11c87b7695e90515120cf8526c225ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 11:06:47 -0400 Subject: [PATCH 01/18] refactor(engine): model local and synced workspace profiles --- crates/engine/src/lib.rs | 59 +++---- crates/engine/src/profile.rs | 294 ++++++++++++++++++++++++++++++++++ crates/engine/src/uploads.rs | 9 +- crates/proto/src/lib.rs | 2 + crates/proto/src/workspace.rs | 35 ++++ 5 files changed, 370 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/profile.rs create mode 100644 crates/proto/src/workspace.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a4662903..4ca0541d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -pub use comet_proto::HarnessId; +pub use comet_proto::{HarnessId, WorkspaceScope}; use comet_sync::DocsStore; @@ -17,6 +17,7 @@ pub mod auth; pub mod diff_sync; pub mod doc_host; pub mod instance_lock; +pub mod profile; pub mod registry; pub mod repos; pub mod rpc; @@ -33,6 +34,7 @@ pub use auth::{Auth, AuthConfig, AuthState, AuthUser, OrgMembership}; pub use diff_sync::{CheckoutDiffSync, DiffSidecar, DiffSnapshot, capture_diff}; pub use doc_host::{ChatDocHandle, DocHost, DocHostConfig, EdgeConfig}; pub use instance_lock::InstanceLock; +pub use profile::EngineProfile; pub use registry::{HarnessDescriptor, HarnessRegistry, default_registry}; pub use repos::{CheckoutIdentity, Repos, worktree_branch_from_title}; pub use rpc::EngineRpc; @@ -104,6 +106,7 @@ pub struct EngineCore { pub uploads: Uploads, pub agent_accounts: AgentAccounts, pub device_id: String, + workspace_scope: WorkspaceScope, /// Auth service (attached by [`Engine::run`]; a lazy dev-mode instance otherwise). auth: std::sync::Mutex>, /// Peer link cache for `targetDeviceId` routing (attached when edge+auth are ready). @@ -128,7 +131,8 @@ impl EngineCore { ) -> Result { let org_id = env_or("COMET_ORG_ID", DEFAULT_ORG_ID); let user_id = env_or("COMET_USER_ID", DEFAULT_USER_ID); - Self::assemble_with_identity(data_dir, registry, default_harness, edge, &org_id, &user_id) + let profile = EngineProfile::development(data_dir, &org_id, &user_id); + Self::assemble_with_profile(profile, registry, default_harness, edge) } pub fn assemble_with_identity( @@ -139,21 +143,26 @@ impl EngineCore { org_id: &str, user_id: &str, ) -> Result { + let profile = EngineProfile::synced(data_dir, org_id, user_id); + Self::assemble_with_profile(profile, registry, default_harness, edge) + } + + /// Assemble the engine against one resolved, immutable workspace profile. + pub fn assemble_with_profile( + profile: EngineProfile, + registry: Arc, + default_harness: HarnessId, + edge: Option, + ) -> Result { + let data_dir = profile.device_root(); std::fs::create_dir_all(data_dir)?; // Single-instance guard: two engines on one data dir would race the // SQLite snapshots + journals. Taken before any store opens or the IPC // port binds; held (and kernel-released on crash) for the engine's life. let lock = InstanceLock::acquire(data_dir)?; let device_id = load_or_create_device_id(data_dir)?; - // Identity-scoped storage: snapshots, the command ledger, and run - // journals live under `orgs/{orgId}/{userId}/` so switching accounts or - // orgs on one machine never reuses another identity's cached docs. - let org_dir = data_dir - .join("orgs") - .join(sanitize_path_id(org_id)) - .join(sanitize_path_id(user_id)); - let store = Arc::new(DocsStore::open(&org_dir)?); - let journal = Arc::new(RunJournal::open(org_dir.join("journals"))?); + let store = Arc::new(DocsStore::open(profile.store_root())?); + let journal = Arc::new(RunJournal::open(profile.store_root().join("journals"))?); let sessions = SessionsEngine::new(device_id.clone(), journal, registry.clone()); let doc_host = DocHost::new( store.clone(), @@ -169,8 +178,8 @@ impl EngineCore { device_id: device_id.clone(), device_name: local_device_name(), platform: std::env::consts::OS.to_string(), - org_id: org_id.to_string(), - user_id: user_id.to_string(), + org_id: profile.org_id().to_string(), + user_id: profile.user_id().to_string(), edge: edge.clone(), }, )?; @@ -184,7 +193,7 @@ impl EngineCore { } let repos = Repos::new(data_dir, &device_id); let terminals = Terminals::new(); - let uploads = Uploads::new(data_dir, edge.clone()); + let uploads = Uploads::from_root(profile.uploads_root(), edge.clone()); let agent_accounts = AgentAccounts::new(AgentAccountsConfig::detect(data_dir)); sessions.set_titles(TitleGenerator::new( workspace.clone(), @@ -205,6 +214,7 @@ impl EngineCore { uploads, agent_accounts, device_id, + workspace_scope: profile.scope(), auth: std::sync::Mutex::new(None), links: std::sync::Mutex::new(None), updater: std::sync::Mutex::new(None), @@ -212,6 +222,10 @@ impl EngineCore { }) } + pub fn workspace_scope(&self) -> WorkspaceScope { + self.workspace_scope + } + /// Attach the auth service (before building the RPC service / relays). pub fn set_auth(&self, auth: Auth) { *self @@ -356,6 +370,10 @@ impl EngineRuntime { &self.core } + pub fn workspace_scope(&self) -> WorkspaceScope { + self.core.workspace_scope() + } + pub async fn shutdown(&self) { self.core.shutdown().await; } @@ -714,19 +732,6 @@ fn env_or(key: &str, default: &str) -> String { .unwrap_or_else(|| default.to_string()) } -/// Filesystem-safe form of an org/user id (path segments for `orgs/{org}/{user}/`). -fn sanitize_path_id(id: &str) -> String { - id.chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') { - c - } else { - '_' - } - }) - .collect() -} - /// Stable per-installation device id, persisted at `{data_dir}/device-id`. fn load_or_create_device_id(data_dir: &Path) -> Result { let path = data_dir.join("device-id"); diff --git a/crates/engine/src/profile.rs b/crates/engine/src/profile.rs new file mode 100644 index 00000000..e284e191 --- /dev/null +++ b/crates/engine/src/profile.rs @@ -0,0 +1,294 @@ +//! Workspace profile identity and storage boundaries. +//! +//! Stores, journals, and uploads are profile-scoped. Repositories, worktrees, +//! agent credentials, UI settings, and the device id remain device-scoped under +//! the engine data directory. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use comet_proto::WorkspaceScope; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::EngineError; + +const LOCAL_PROFILE_FILE: &str = "local-profile.json"; +const LOCAL_ORG_ID: &str = "local"; + +/// A resolved, immutable identity and storage boundary for one engine runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EngineProfile { + scope: WorkspaceScope, + device_root: PathBuf, + store_root: PathBuf, + uploads_root: PathBuf, + org_id: String, + user_id: String, +} + +impl EngineProfile { + /// Load or create the installation's stable local identity. + pub fn local(data_dir: &Path) -> Result { + let profile_id = load_or_create_local_profile_id(data_dir)?; + let store_root = data_dir.join("profiles").join("local"); + Ok(Self { + scope: WorkspaceScope::Local, + device_root: data_dir.to_path_buf(), + uploads_root: store_root.join("uploads"), + store_root, + org_id: LOCAL_ORG_ID.to_string(), + user_id: profile_id, + }) + } + + /// Resolve an authenticated profile while preserving the historical cloud layout. + pub fn synced(data_dir: &Path, org_id: &str, user_id: &str) -> Self { + Self::account_scoped(data_dir, org_id, user_id, WorkspaceScope::Synced) + } + + /// Resolve the explicit development profile using the historical cloud layout. + pub fn development(data_dir: &Path, org_id: &str, user_id: &str) -> Self { + Self::account_scoped(data_dir, org_id, user_id, WorkspaceScope::Development) + } + + fn account_scoped(data_dir: &Path, org_id: &str, user_id: &str, scope: WorkspaceScope) -> Self { + Self { + scope, + device_root: data_dir.to_path_buf(), + store_root: data_dir + .join("orgs") + .join(sanitize_path_id(org_id)) + .join(sanitize_path_id(user_id)), + uploads_root: data_dir.join("uploads"), + org_id: org_id.to_string(), + user_id: user_id.to_string(), + } + } + + pub fn scope(&self) -> WorkspaceScope { + self.scope + } + + pub fn device_root(&self) -> &Path { + &self.device_root + } + + pub fn store_root(&self) -> &Path { + &self.store_root + } + + pub fn uploads_root(&self) -> &Path { + &self.uploads_root + } + + pub fn org_id(&self) -> &str { + &self.org_id + } + + pub fn user_id(&self) -> &str { + &self.user_id + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalProfileFile { + id: Uuid, +} + +fn load_or_create_local_profile_id(data_dir: &Path) -> Result { + std::fs::create_dir_all(data_dir)?; + let path = data_dir.join(LOCAL_PROFILE_FILE); + match read_local_profile_id(&path) { + Ok(id) => return Ok(id), + Err(ProfileReadError::Missing) => {} + Err(ProfileReadError::Engine(err)) => return Err(err), + } + + let id = Uuid::new_v4(); + let mut bytes = serde_json::to_vec_pretty(&LocalProfileFile { id }) + .map_err(|err| EngineError::Other(format!("serialize local profile: {err}")))?; + bytes.push(b'\n'); + + // Publish a fully-written same-directory file without replacing a profile + // another process may have created concurrently. + let temp_path = data_dir.join(format!( + ".{LOCAL_PROFILE_FILE}.tmp-{}-{}", + std::process::id(), + Uuid::new_v4() + )); + let write_result = (|| -> Result<(), EngineError> { + let mut temp = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path)?; + temp.write_all(&bytes)?; + temp.sync_all()?; + Ok(()) + })(); + if let Err(err) = write_result { + let _ = std::fs::remove_file(&temp_path); + return Err(err); + } + + let publish_result = std::fs::hard_link(&temp_path, &path); + let _ = std::fs::remove_file(&temp_path); + match publish_result { + Ok(()) => Ok(id.to_string()), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + read_local_profile_id(&path).map_err(ProfileReadError::into_engine) + } + Err(err) => Err(err.into()), + } +} + +enum ProfileReadError { + Missing, + Engine(EngineError), +} + +impl ProfileReadError { + fn into_engine(self) -> EngineError { + match self { + Self::Missing => EngineError::Other("local profile disappeared during creation".into()), + Self::Engine(err) => err, + } + } +} + +fn read_local_profile_id(path: &Path) -> Result { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(ProfileReadError::Missing); + } + Err(err) => return Err(ProfileReadError::Engine(err.into())), + }; + let profile: LocalProfileFile = serde_json::from_slice(&bytes).map_err(|err| { + ProfileReadError::Engine(EngineError::Other(format!( + "invalid local profile {}: {err}", + path.display() + ))) + })?; + if profile.id.is_nil() { + return Err(ProfileReadError::Engine(EngineError::Other(format!( + "invalid local profile {}: id must not be nil", + path.display() + )))); + } + Ok(profile.id.to_string()) +} + +/// Filesystem-safe form of an org/user id used by the historical synced layout. +fn sanitize_path_id(id: &str) -> String { + id.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') { + c + } else { + '_' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_profile_id_is_stable_and_not_the_dev_identity() { + let dir = tempfile::tempdir().unwrap(); + let first = EngineProfile::local(dir.path()).unwrap(); + let second = EngineProfile::local(dir.path()).unwrap(); + + assert!(!first.user_id().is_empty()); + assert_ne!(first.user_id(), "dev-user"); + assert_eq!(first.user_id(), second.user_id()); + assert!(Uuid::parse_str(first.user_id()).is_ok()); + } + + #[test] + fn local_profiles_in_different_data_dirs_have_different_ids() { + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + + let first = EngineProfile::local(first_dir.path()).unwrap(); + let second = EngineProfile::local(second_dir.path()).unwrap(); + + assert_ne!(first.user_id(), second.user_id()); + } + + #[test] + fn local_profile_resolves_isolated_store_and_upload_roots() { + let dir = tempfile::tempdir().unwrap(); + let profile = EngineProfile::local(dir.path()).unwrap(); + let local_root = dir.path().join("profiles/local"); + + assert_eq!(profile.scope(), WorkspaceScope::Local); + assert_eq!(profile.store_root(), local_root); + assert_eq!(profile.uploads_root(), local_root.join("uploads")); + } + + #[test] + fn synced_profile_preserves_historical_roots() { + let dir = tempfile::tempdir().unwrap(); + let profile = EngineProfile::synced(dir.path(), "org/example", "user@example.com"); + + assert_eq!(profile.scope(), WorkspaceScope::Synced); + assert_eq!( + profile.store_root(), + dir.path().join("orgs/org_example/user_example_com") + ); + assert_eq!(profile.uploads_root(), dir.path().join("uploads")); + } + + #[tokio::test] + async fn profile_assembler_uses_the_resolved_local_roots() { + let dir = tempfile::tempdir().unwrap(); + let profile = EngineProfile::local(dir.path()).unwrap(); + let core = crate::EngineCore::assemble_with_profile( + profile, + std::sync::Arc::new(crate::default_registry()), + comet_proto::HarnessId::Mock, + None, + ) + .unwrap(); + + assert_eq!(core.workspace_scope(), WorkspaceScope::Local); + assert_eq!( + core.uploads.dir(), + dir.path().join("profiles/local/uploads") + ); + assert!(dir.path().join("profiles/local").is_dir()); + assert!(!dir.path().join("orgs/dev-org/dev-user").exists()); + core.shutdown().await; + } + + #[tokio::test] + async fn development_constructor_preserves_the_historical_layout() { + let dir = tempfile::tempdir().unwrap(); + let org_id = crate::env_or("COMET_ORG_ID", crate::DEFAULT_ORG_ID); + let user_id = crate::env_or("COMET_USER_ID", crate::DEFAULT_USER_ID); + let expected = EngineProfile::development(dir.path(), &org_id, &user_id); + let core = crate::EngineCore::assemble( + dir.path(), + std::sync::Arc::new(crate::default_registry()), + comet_proto::HarnessId::Mock, + None, + ) + .unwrap(); + + assert_eq!(core.workspace_scope(), WorkspaceScope::Development); + assert_eq!(core.uploads.dir(), dir.path().join("uploads")); + assert!(expected.store_root().is_dir()); + if std::env::var("COMET_ORG_ID").is_err() && std::env::var("COMET_USER_ID").is_err() { + assert_eq!( + expected.store_root(), + dir.path().join("orgs/dev-org/dev-user") + ); + } + core.shutdown().await; + } +} diff --git a/crates/engine/src/uploads.rs b/crates/engine/src/uploads.rs index 1637913a..5d956128 100644 --- a/crates/engine/src/uploads.rs +++ b/crates/engine/src/uploads.rs @@ -67,12 +67,17 @@ pub struct Uploads { } impl Uploads { + /// Use the historical device-global uploads directory. pub fn new(data_dir: &Path, edge: Option) -> Self { - let dir = data_dir.join("uploads"); + Self::from_root(&data_dir.join("uploads"), edge) + } + + /// Use an already-resolved profile uploads directory. + pub fn from_root(dir: &Path, edge: Option) -> Self { Self { inner: Arc::new(UploadsInner { tmp: dir.join("tmp"), - dir, + dir: dir.to_path_buf(), edge, http: reqwest::Client::builder() .timeout(Duration::from_secs(30)) diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index f878fea3..f6ba30f4 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -8,6 +8,8 @@ pub mod agent; pub mod entities; pub mod motion; pub mod view; +pub mod workspace; pub use agent::*; pub use entities::*; +pub use workspace::*; diff --git a/crates/proto/src/workspace.rs b/crates/proto/src/workspace.rs new file mode 100644 index 00000000..b6d7ed34 --- /dev/null +++ b/crates/proto/src/workspace.rs @@ -0,0 +1,35 @@ +//! Workspace lifecycle types shared by the engine and its clients. + +use serde::{Deserialize, Serialize}; + +/// The fixed data boundary selected when an engine runtime is assembled. +/// +/// Authentication can change while a runtime is alive, but its workspace scope +/// cannot. Switching scopes requires assembling a new runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorkspaceScope { + Local, + Synced, + Development, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_scope_uses_wire_safe_names() { + for (scope, encoded) in [ + (WorkspaceScope::Local, "\"local\""), + (WorkspaceScope::Synced, "\"synced\""), + (WorkspaceScope::Development, "\"development\""), + ] { + assert_eq!(serde_json::to_string(&scope).unwrap(), encoded); + assert_eq!( + serde_json::from_str::(encoded).unwrap(), + scope + ); + } + } +} From 98c2045c5aaf20e7bf39639ae2cef61016b79dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 11:26:15 -0400 Subject: [PATCH 02/18] feat(engine): boot signed-out installations in local-only mode --- crates/engine/src/auth.rs | 11 ++ crates/engine/src/lib.rs | 133 ++++++++++++++----- crates/engine/src/rpc.rs | 15 ++- crates/engine/tests/auth.rs | 11 ++ crates/engine/tests/local_first.rs | 164 ++++++++++++++++++++++++ crates/proto/src/workspace.rs | 23 ++++ crates/rpc/src/lib.rs | 2 + crates/ui/src/state.rs | 199 +++++++++++++++++++++++++---- 8 files changed, 495 insertions(+), 63 deletions(-) create mode 100644 crates/engine/tests/local_first.rs diff --git a/crates/engine/src/auth.rs b/crates/engine/src/auth.rs index a961992f..e78bd4e7 100644 --- a/crates/engine/src/auth.rs +++ b/crates/engine/src/auth.rs @@ -208,6 +208,9 @@ struct AuthInner { config: AuthConfig, /// `Some(client_id)` = WorkOS mode; `None` = dev mode. workos: Option, + /// Whether construction loaded a parseable WorkOS session. This is an + /// immutable startup fact: refresh or sign-out must not rewrite it. + loaded_workos_session: bool, http: reqwest::Client, state_tx: watch::Sender, stored: Mutex>, @@ -254,6 +257,7 @@ impl Auth { (Some(_), Some(session)) => state_for(session.user.clone(), session.org_id.clone()), (Some(_), None) => AuthState::SignedOut, }; + let loaded_workos_session = workos.is_some() && stored.is_some(); let (state_tx, _) = watch::channel(initial); let http = reqwest::Client::builder() .timeout(HTTP_TIMEOUT) @@ -263,6 +267,7 @@ impl Auth { inner: Arc::new(AuthInner { config, workos, + loaded_workos_session, http, state_tx, stored: Mutex::new(stored), @@ -309,6 +314,12 @@ impl Auth { self.inner.workos.is_some() } + /// True when construction loaded a parseable persisted WorkOS session. + /// The value stays true even if a later refresh revokes that session. + pub fn loaded_workos_session(&self) -> bool { + self.inner.loaded_workos_session + } + /// Live auth status (current value + changes). pub fn watch_state(&self) -> watch::Receiver { self.inner.state_tx.subscribe() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4ca0541d..cb5798e8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -pub use comet_proto::{HarnessId, WorkspaceScope}; +pub use comet_proto::{EngineInfo, HarnessId, WorkspaceScope}; use comet_sync::DocsStore; @@ -330,6 +330,7 @@ impl EngineCore { self.diff_sync.clone(), self.uploads.clone(), self.agent_accounts.clone(), + self.workspace_scope, ) .with_auth(self.auth()); if let Some(links) = self.links() { @@ -385,8 +386,8 @@ impl Engine { } /// Resolve the shared dev/WorkOS auth configuration for headed and headless - /// modes. Production callers pass the baked WorkOS client id; explicit dev - /// bearers still opt into the local dev identity. + /// modes. A clean WorkOS boot deliberately avoids probing Edge: signed-out + /// installations must be able to start locally without network access. pub async fn build_auth(config: &EngineConfig) -> Auth { let mut auth_config = AuthConfig::new(config.edge_url.clone(), config.data_dir.clone()); auth_config.workos_client_id = config.workos_client_id.clone(); @@ -404,46 +405,103 @@ impl Engine { if let Some(token) = &config.edge_token { auth_config.dev_user_id = token.clone(); } - Auth::detect(auth_config).await + Auth::new(auth_config) } - /// Open the identity-scoped stores and online transports for an auth session - /// that is already ready. The headed UI waits behind its sign-in gate before - /// calling this; headless mode waits on the terminal flow. + /// Capture the workspace boundary once, before refresh or sign-in can mutate auth. + pub fn initial_workspace_scope(auth: &Auth) -> WorkspaceScope { + if !auth.workos_enabled() { + WorkspaceScope::Development + } else if auth.loaded_workos_session() { + WorkspaceScope::Synced + } else { + WorkspaceScope::Local + } + } + + /// Resolve a profile for the captured scope. A synced session without an + /// organization returns `None` until onboarding selects one; it never falls + /// through to the local or development profile. + pub fn resolve_profile( + config: &EngineConfig, + auth: &Auth, + scope: WorkspaceScope, + ) -> Result, EngineError> { + match scope { + WorkspaceScope::Local => EngineProfile::local(&config.data_dir).map(Some), + WorkspaceScope::Development => { + let dev_token_org = config + .edge_token + .as_deref() + .and_then(|token| token.split_once('@')) + .map(|(_, org)| org.to_string()) + .filter(|org| !org.is_empty()); + let org_id = dev_token_org + .or(config.org_id.clone()) + .unwrap_or_else(|| env_or("COMET_ORG_ID", DEFAULT_ORG_ID)); + let user_id = auth + .user_id() + .unwrap_or_else(|| env_or("COMET_USER_ID", DEFAULT_USER_ID)); + Ok(Some(EngineProfile::development( + &config.data_dir, + &org_id, + &user_id, + ))) + } + WorkspaceScope::Synced => { + let state = auth.state(); + let Some(user) = state.user() else { + return Err(EngineError::Other( + "captured synced session no longer exposes its user identity".into(), + )); + }; + let Some(org_id) = state.org_id() else { + return Ok(None); + }; + Ok(Some(EngineProfile::synced( + &config.data_dir, + org_id, + &user.id, + ))) + } + } + } + + /// Resolve the one-shot identity served before profile stores are available. + pub fn engine_info( + config: &EngineConfig, + workspace_scope: WorkspaceScope, + ) -> Result { + std::fs::create_dir_all(&config.data_dir)?; + Ok(EngineInfo { + device_id: load_or_create_device_id(&config.data_dir)?, + workspace_scope, + }) + } + + /// Open one already-resolved profile and enable online transports only for + /// synced and explicit development runtimes. pub async fn assemble_runtime( config: &EngineConfig, auth: Auth, + profile: EngineProfile, ) -> anyhow::Result { - let online = (auth.workos_enabled() || config.edge_token.is_some()) - && auth.access_token().await.is_some(); - let device_id = load_or_create_device_id(&config.data_dir)?; + let online = match profile.scope() { + WorkspaceScope::Local => false, + WorkspaceScope::Synced | WorkspaceScope::Development => { + auth.access_token().await.is_some() + } + }; + let device_id = load_or_create_device_id(profile.device_root())?; let edge = online.then(|| { EdgeConfig::new(config.edge_url.clone(), Arc::new(auth.clone())).with_device(device_id) }); - let dev_token_org = config - .edge_token - .as_deref() - .and_then(|t| t.split_once('@')) - .map(|(_, org)| org.to_string()) - .filter(|s| !s.is_empty()); - let org_id = auth - .state() - .org_id() - .map(str::to_string) - .or(dev_token_org) - .or(config.org_id.clone()) - .unwrap_or_else(|| env_or("COMET_ORG_ID", DEFAULT_ORG_ID)); - let user_id = auth - .user_id() - .unwrap_or_else(|| env_or("COMET_USER_ID", DEFAULT_USER_ID)); - let core = EngineCore::assemble_with_identity( - &config.data_dir, + let core = EngineCore::assemble_with_profile( + profile, Arc::new(default_registry()), config.default_harness, edge.clone(), - &org_id, - &user_id, )?; core.set_auth(auth.clone()); // Release checker: polls {edge}/releases on a 6h cadence; headless @@ -489,16 +547,21 @@ impl Engine { std::fs::create_dir_all(&config.data_dir)?; let auth = Self::build_auth(&config).await; + let workspace_scope = Self::initial_workspace_scope(&auth); + let mut profile = Self::resolve_profile(&config, &auth, workspace_scope)?; let _refresh_loop = auth.spawn_refresh_loop(); - // WorkOS mode: gate edge features on a signed-in, org-scoped session. A TTY - // gets the interactive paste-code flow; a service manager (systemd/launchd) - // fails fast with a "run `comet login`" error instead of hanging on a prompt. - if auth.workos_enabled() { + // A captured cloud session without an organization must finish onboarding + // before its profile can open. A clean signed-out install is local and never + // enters the terminal sign-in flow. + if workspace_scope == WorkspaceScope::Synced && profile.is_none() { terminal_sign_in(&auth).await?; + profile = Self::resolve_profile(&config, &auth, workspace_scope)?; } + let profile = profile + .ok_or_else(|| EngineError::Other("synced workspace profile is not ready".into()))?; - let runtime = Self::assemble_runtime(&config, auth).await?; + let runtime = Self::assemble_runtime(&config, auth, profile).await?; // A daemon exists to serve this port, so a bind failure is fatal here — // unlike the headed app, which can still work over its in-process diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 47b650ab..c5dd1d66 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -12,7 +12,9 @@ //! remote devices' workspace session rows //! - `Mutate {op, …}` → `{ok}` — workspace entity mutations (createChat, renameChat, //! setChatArchived, deleteChat, renameDevice, markChatSeen) -//! - `LocalDevice` → `{deviceId}` — this engine's identity (never forwarded) +//! - `EngineInfo` → `{deviceId, workspaceScope}` — this runtime's fixed identity +//! and data boundary (never forwarded) +//! - `LocalDevice` → `{deviceId}` — legacy engine identity (never forwarded) //! - AuthRpc (feature-inventory §2): `AuthStatus` (stream), `SignIn`/`SignInHeadless` → //! `{url}`, `CompleteSignIn {code}`, `SignOut`, `ListOrgs`, `CreateOrg {name}`, //! `SelectOrg {organizationId}` @@ -55,7 +57,7 @@ use std::time::Duration; use tokio::sync::watch; use comet_doc::{MessagePart, SessionCommandPayload}; -use comet_proto::{ChatConfig, HarnessId, ToolCall}; +use comet_proto::{ChatConfig, EngineInfo, HarnessId, ToolCall, WorkspaceScope}; use comet_rpc::{LinkCache, RpcError, RpcReply, RpcService, methods, parse_params}; use crate::agent_accounts::AgentAccounts; @@ -360,6 +362,7 @@ pub struct EngineRpc { auth: Option, links: Option>, updater: Option, + engine_info: EngineInfo, } impl EngineRpc { @@ -374,7 +377,12 @@ impl EngineRpc { diff_sync: CheckoutDiffSync, uploads: Uploads, agent_accounts: AgentAccounts, + workspace_scope: WorkspaceScope, ) -> Self { + let engine_info = EngineInfo { + device_id: doc_host.device_id().to_string(), + workspace_scope, + }; Self { sessions, doc_host, @@ -388,6 +396,7 @@ impl EngineRpc { auth: None, links: None, updater: None, + engine_info, } } @@ -941,6 +950,7 @@ impl RpcService for EngineRpc { .await; } match method { + methods::ENGINE_INFO => RpcReply::value(&self.engine_info), methods::LIST_HARNESSES => RpcReply::value(&self.registry.descriptors()), methods::LIST_MODELS => { let p: ListModelsParams = parse_params(params)?; @@ -1349,6 +1359,7 @@ mod tests { #[test] fn local_device_is_not_forwardable() { assert!(!forwardable(methods::LOCAL_DEVICE)); + assert!(!forwardable(methods::ENGINE_INFO)); assert!(forwardable(methods::QUEUE_COMMAND)); assert!(forwardable(methods::SEARCH_FILES)); } diff --git a/crates/engine/tests/auth.rs b/crates/engine/tests/auth.rs index 70f254fa..cb549223 100644 --- a/crates/engine/tests/auth.rs +++ b/crates/engine/tests/auth.rs @@ -261,6 +261,7 @@ async fn dev_mode_is_signed_in_with_configured_bearer() { config.dev_user_id = "wing-dev".into(); let auth = Auth::new(config); assert!(!auth.workos_enabled()); + assert!(!auth.loaded_workos_session()); assert!(matches!(auth.state(), AuthState::SignedIn { user, .. } if user.id == "wing-dev")); assert_eq!(auth.access_token().await.as_deref(), Some("wing-dev")); // Dev sign-in mirrors the TS service: a no-op URL, CompleteSignIn accepted. @@ -276,6 +277,7 @@ async fn headless_flow_exchanges_pasted_code_and_gates_on_org() { let dir = tempfile::tempdir().expect("tempdir"); let auth = Auth::new(workos_config(&edge.url(), dir.path())); assert!(auth.workos_enabled()); + assert!(!auth.loaded_workos_session()); assert_eq!(auth.state(), AuthState::SignedOut); assert_eq!(auth.access_token().await, None, "signed out: no token"); @@ -300,6 +302,10 @@ async fn headless_flow_exchanges_pasted_code_and_gates_on_org() { auth.complete_sign_in(&format!("{state}.code123")) .await .expect("paste-code sign-in"); + assert!( + !auth.loaded_workos_session(), + "sign-in does not rewrite the startup fact" + ); assert_eq!(edge.state.exchanges.load(Ordering::SeqCst), 1); assert!( matches!(auth.state(), AuthState::NeedsOrganization { user } if user.email == "w@example.com") @@ -400,10 +406,15 @@ async fn revoked_refresh_token_signs_out() { auth.state().is_signed_in(), "boots from the persisted session" ); + assert!(auth.loaded_workos_session()); // The refresh is doomed → the session degrades to SignedOut and the file is gone. assert_eq!(auth.access_token().await, None); assert_eq!(auth.state(), AuthState::SignedOut); + assert!( + auth.loaded_workos_session(), + "revocation must not rewrite the captured startup fact" + ); assert!(!dir.path().join("session.json").exists()); } diff --git a/crates/engine/tests/local_first.rs b/crates/engine/tests/local_first.rs new file mode 100644 index 00000000..4863f6ad --- /dev/null +++ b/crates/engine/tests/local_first.rs @@ -0,0 +1,164 @@ +//! Local-first startup boundaries and captured synced-session behavior. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use comet_engine::{AuthState, Engine, EngineConfig, EngineInfo, HarnessId, WorkspaceScope}; +use comet_rpc::{memory_client, methods}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +fn config( + data_dir: &std::path::Path, + edge_url: String, + workos_client_id: Option<&str>, + edge_token: Option<&str>, +) -> EngineConfig { + EngineConfig { + data_dir: data_dir.to_path_buf(), + edge_url, + edge_token: edge_token.map(str::to_string), + ipc_port: 0, + default_harness: HarnessId::Mock, + org_id: None, + workos_client_id: workos_client_id.map(str::to_string), + } +} + +async fn rejecting_edge() -> (String, Arc, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let requests = Arc::new(AtomicUsize::new(0)); + let seen = requests.clone(); + let task = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + let seen = seen.clone(); + tokio::spawn(async move { + let mut request = [0u8; 4096]; + let _ = stream.read(&mut request).await; + seen.fetch_add(1, Ordering::SeqCst); + let body = r#"{"error":"revoked"}"#; + let response = format!( + "HTTP/1.1 401 Unauthorized\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + (format!("http://127.0.0.1:{port}"), requests, task) +} + +#[tokio::test] +async fn signed_out_workos_boot_serves_local_data_without_dev_identity() { + let dir = tempfile::tempdir().unwrap(); + let config = config( + dir.path(), + "http://127.0.0.1:1".into(), + Some("client_test"), + None, + ); + let auth = Engine::build_auth(&config).await; + let scope = Engine::initial_workspace_scope(&auth); + let profile = Engine::resolve_profile(&config, &auth, scope) + .unwrap() + .expect("local profile is ready without auth"); + + assert_eq!(scope, WorkspaceScope::Local); + let runtime = Engine::assemble_runtime(&config, auth, profile) + .await + .unwrap(); + let client = memory_client(runtime.core().rpc_service()); + let info: EngineInfo = client + .call_as(methods::ENGINE_INFO, serde_json::json!({})) + .await + .unwrap(); + assert_eq!(info.workspace_scope, WorkspaceScope::Local); + assert!( + client + .call(methods::LIST_HARNESSES, serde_json::json!({})) + .await + .unwrap() + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + assert!(dir.path().join("profiles/local").is_dir()); + assert!(!dir.path().join("orgs/dev-org/dev-user").exists()); + assert!(runtime.core().links().is_none()); + runtime.shutdown().await; +} + +#[tokio::test] +async fn clean_local_auth_construction_does_not_probe_edge_health() { + let dir = tempfile::tempdir().unwrap(); + let (edge_url, requests, edge_task) = rejecting_edge().await; + let config = config(dir.path(), edge_url, Some("client_test"), None); + + let auth = Engine::build_auth(&config).await; + + assert_eq!( + Engine::initial_workspace_scope(&auth), + WorkspaceScope::Local + ); + assert_eq!(requests.load(Ordering::SeqCst), 0); + edge_task.abort(); +} + +#[tokio::test] +async fn revoked_captured_session_stays_on_its_synced_cache() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("session.json"), + r#"{"refreshToken":"dead","user":{"id":"user_1","email":"u@example.com"},"orgId":"org_1"}"#, + ) + .unwrap(); + let (edge_url, requests, edge_task) = rejecting_edge().await; + let config = config(dir.path(), edge_url, Some("client_test"), None); + let auth = Engine::build_auth(&config).await; + let scope = Engine::initial_workspace_scope(&auth); + let profile = Engine::resolve_profile(&config, &auth, scope) + .unwrap() + .expect("persisted org resolves before refresh"); + + assert!(auth.loaded_workos_session()); + assert_eq!(scope, WorkspaceScope::Synced); + let runtime = Engine::assemble_runtime(&config, auth.clone(), profile) + .await + .unwrap(); + + assert_eq!(auth.state(), AuthState::SignedOut); + assert!(auth.loaded_workos_session()); + assert_eq!(runtime.workspace_scope(), WorkspaceScope::Synced); + assert!(dir.path().join("orgs/org_1/user_1").is_dir()); + assert!(!dir.path().join("profiles/local").exists()); + assert!(requests.load(Ordering::SeqCst) >= 1); + runtime.shutdown().await; + edge_task.abort(); +} + +#[tokio::test] +async fn explicit_dev_bearer_keeps_online_routing_enabled() { + let dir = tempfile::tempdir().unwrap(); + let config = config( + dir.path(), + "http://127.0.0.1:1".into(), + None, + Some("dev-user@dev-org"), + ); + let auth = Engine::build_auth(&config).await; + let scope = Engine::initial_workspace_scope(&auth); + let profile = Engine::resolve_profile(&config, &auth, scope) + .unwrap() + .expect("development profile is ready"); + + let runtime = Engine::assemble_runtime(&config, auth, profile) + .await + .unwrap(); + + assert_eq!(runtime.workspace_scope(), WorkspaceScope::Development); + assert!(runtime.core().links().is_some()); + assert!(dir.path().join("orgs/dev-org/dev-user").is_dir()); + runtime.shutdown().await; +} diff --git a/crates/proto/src/workspace.rs b/crates/proto/src/workspace.rs index b6d7ed34..13ffc0de 100644 --- a/crates/proto/src/workspace.rs +++ b/crates/proto/src/workspace.rs @@ -14,6 +14,14 @@ pub enum WorkspaceScope { Development, } +/// Stable information about the engine runtime reached by a client. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EngineInfo { + pub device_id: String, + pub workspace_scope: WorkspaceScope, +} + #[cfg(test)] mod tests { use super::*; @@ -32,4 +40,19 @@ mod tests { ); } } + + #[test] + fn engine_info_uses_camel_case_fields() { + let info = EngineInfo { + device_id: "device-1".into(), + workspace_scope: WorkspaceScope::Local, + }; + assert_eq!( + serde_json::to_value(&info).unwrap(), + serde_json::json!({ + "deviceId": "device-1", + "workspaceScope": "local", + }) + ); + } } diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 603a98c9..2fafedaa 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -59,6 +59,8 @@ pub mod methods { /// This engine's identity → `{deviceId}` (IPC-only; never relay-forwarded — /// the answer is about whichever engine you are directly connected to). pub const LOCAL_DEVICE: &str = "LocalDevice"; + /// This engine runtime's fixed device and workspace identity. + pub const ENGINE_INFO: &str = "EngineInfo"; pub const AUTH_STATUS: &str = "AuthStatus"; // AuthRpc mutations (feature-inventory §2 AuthRpc; IPC-only). pub const SIGN_IN: &str = "SignIn"; diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index a07052b0..0685477e 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -29,7 +29,9 @@ use serde::de::DeserializeOwned; use comet_doc::{SessionMessageEntry, TranscriptDesync, TranscriptFrame}; use comet_engine::{Engine, EngineConfig, EngineRuntime, rpc::AuthRpc}; -use comet_proto::{AuthState, Chat, ChatIndicator, Device, HarnessId, Session, Space}; +use comet_proto::{ + AuthState, Chat, ChatIndicator, Device, EngineInfo, HarnessId, Session, Space, WorkspaceScope, +}; use comet_rpc::{RpcClient, RpcError, RpcReply, RpcService, connect_ws, memory_client, methods}; // --------------------------------------------------------------------------- @@ -115,17 +117,21 @@ enum DeferredEngineState { Failed(String), } -/// Serves AuthRpc immediately, then holds all data RPC calls until the signed-in -/// user's identity-scoped engine is assembled. Existing UI subscriptions remain -/// pending and attach to the real service without reconnecting. +/// Serves engine identity and AuthRpc immediately, then holds data calls only +/// while a captured synced profile still needs organization onboarding. +/// Existing subscriptions attach to the assembled service without reconnecting. struct DeferredEngineRpc { auth: AuthRpc, + engine_info: EngineInfo, state: tokio::sync::watch::Receiver, } #[async_trait] impl RpcService for DeferredEngineRpc { async fn handle(&self, method: &str, params: serde_json::Value) -> Result { + if method == methods::ENGINE_INFO { + return RpcReply::value(&self.engine_info); + } if AuthRpc::handles(method) { return self.auth.handle(method, params).await; } @@ -170,6 +176,7 @@ impl EngineBackend for RemoteEngine { #[derive(Clone)] pub struct EngineHandle { inner: Arc, + engine_info: EngineInfo, } impl EngineHandle { @@ -186,11 +193,19 @@ impl EngineHandle { if matches!(probe, Ok(Ok(_))) { tracing::info!(%url, "engine daemon detected; connecting"); match connect_ws(&url).await { - Ok(client) => { - return Ok(EngineHandle { - inner: Arc::new(RemoteEngine { client, url }), - }); - } + Ok(client) => match query_engine_info(&client).await { + Ok(engine_info) => { + return Ok(EngineHandle { + inner: Arc::new(RemoteEngine { client, url }), + engine_info, + }); + } + Err(err) => tracing::warn!( + %url, + error = %err, + "listener did not provide engine identity; embedding instead" + ), + }, // Something is on the port but it is not an engine (or it is // wedged). Fall through and embed: a stranger holding 27654 // should cost other viewports, not this window. @@ -209,10 +224,14 @@ impl EngineHandle { workos_client_id: config.workos_client_id, }; let auth = Engine::build_auth(&engine_config).await; + let workspace_scope = Engine::initial_workspace_scope(&auth); + let initial_profile = Engine::resolve_profile(&engine_config, &auth, workspace_scope)?; + let engine_info = Engine::engine_info(&engine_config, workspace_scope)?; let refresh_task = auth.spawn_refresh_loop(); let (state_tx, state_rx) = tokio::sync::watch::channel(DeferredEngineState::Waiting); let service: Arc = Arc::new(DeferredEngineRpc { auth: AuthRpc::new(auth.clone()), + engine_info: engine_info.clone(), state: state_rx, }); let client = memory_client(service.clone()); @@ -220,8 +239,8 @@ impl EngineHandle { // Serve the same service on the IPC port so a terminal viewport can // attach to this window's engine with no setup. Deliberately the // *deferred* service, not the assembled one: a viewport that connects - // before sign-in gets AuthRpc (so it can show its own gate) and its - // data subscriptions wait exactly as this window's do. + // during cloud onboarding gets EngineInfo and AuthRpc immediately, and + // its data subscriptions wait exactly as this window's do. // // Best-effort — losing the bind race with another engine costs other // viewports, not this one. @@ -239,17 +258,35 @@ impl EngineHandle { let runtime = Arc::new(tokio::sync::Mutex::new(None)); let runtime_for_boot = runtime.clone(); let boot_task = tokio::spawn(async move { - let mut auth_state = auth.watch_state(); - while !auth_state.borrow().is_signed_in() { - if auth_state.changed().await.is_err() { - state_tx.send_replace(DeferredEngineState::Failed( - "authentication state closed before sign-in".into(), - )); - return; + let profile = match initial_profile { + Some(profile) => profile, + None => { + let mut auth_state = auth.watch_state(); + while !auth_state.borrow().is_signed_in() { + if auth_state.changed().await.is_err() { + state_tx.send_replace(DeferredEngineState::Failed( + "authentication state closed before workspace onboarding".into(), + )); + return; + } + } + match Engine::resolve_profile(&engine_config, &auth, workspace_scope) { + Ok(Some(profile)) => profile, + Ok(None) => { + state_tx.send_replace(DeferredEngineState::Failed( + "workspace onboarding completed without an organization".into(), + )); + return; + } + Err(err) => { + state_tx.send_replace(DeferredEngineState::Failed(err.to_string())); + return; + } + } } - } + }; - match Engine::assemble_runtime(&engine_config, auth).await { + match Engine::assemble_runtime(&engine_config, auth, profile).await { Ok(engine_runtime) => { let service: Arc = engine_runtime.core().rpc_service(); *runtime_for_boot.lock().await = Some(engine_runtime); @@ -269,6 +306,7 @@ impl EngineHandle { ipc_task, client, }), + engine_info, }) } @@ -280,11 +318,43 @@ impl EngineHandle { self.inner.mode() } + pub fn engine_info(&self) -> &EngineInfo { + &self.engine_info + } + pub async fn shutdown(&self) { self.inner.shutdown().await; } } +/// Query the current protocol first, with a conservative fallback for daemons +/// from before `EngineInfo` existed. Old daemons are always treated as synced. +async fn query_engine_info(client: &RpcClient) -> Result { + match client + .call_as(methods::ENGINE_INFO, serde_json::json!({})) + .await + { + Ok(info) => Ok(info), + Err(RpcError::Failed(message)) + if message == format!("unknown method: {}", methods::ENGINE_INFO) => + { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct LocalDevice { + device_id: String, + } + let legacy: LocalDevice = client + .call_as(methods::LOCAL_DEVICE, serde_json::json!({})) + .await?; + Ok(EngineInfo { + device_id: legacy.device_id, + workspace_scope: WorkspaceScope::Synced, + }) + } + Err(err) => Err(err), + } +} + // --------------------------------------------------------------------------- // Pure state + reducers // --------------------------------------------------------------------------- @@ -1079,6 +1149,34 @@ mod tests { listener.local_addr().unwrap().port() } + struct LegacyIdentityRpc; + + #[async_trait] + impl RpcService for LegacyIdentityRpc { + async fn handle( + &self, + method: &str, + _params: serde_json::Value, + ) -> Result { + match method { + methods::LOCAL_DEVICE => { + RpcReply::value(&serde_json::json!({ "deviceId": "legacy-device" })) + } + other => Err(RpcError::UnknownMethod(other.into())), + } + } + } + + #[tokio::test] + async fn legacy_daemon_identity_falls_back_to_synced_scope() { + let client = memory_client(Arc::new(LegacyIdentityRpc)); + + let info = query_engine_info(&client).await.unwrap(); + + assert_eq!(info.device_id, "legacy-device"); + assert_eq!(info.workspace_scope, WorkspaceScope::Synced); + } + #[tokio::test] async fn bootstrap_embeds_engine_when_port_is_free() { let dir = tempfile::tempdir().unwrap(); @@ -1181,7 +1279,7 @@ mod tests { } #[tokio::test] - async fn production_bootstrap_requires_sign_in_before_opening_engine_data() { + async fn production_bootstrap_opens_local_data_without_sign_in() { let dir = tempfile::tempdir().unwrap(); let handle = EngineHandle::bootstrap(EngineBootConfig { data_dir: dir.path().to_path_buf(), @@ -1195,6 +1293,14 @@ mod tests { .await .unwrap(); + assert_eq!(handle.engine_info().workspace_scope, WorkspaceScope::Local); + let info: EngineInfo = handle + .client() + .call_as(methods::ENGINE_INFO, serde_json::json!({})) + .await + .unwrap(); + assert_eq!(info, *handle.engine_info()); + let mut auth = handle .client() .subscribe(methods::AUTH_STATUS, serde_json::json!({})) @@ -1204,6 +1310,46 @@ mod tests { parse_auth_state(&auth.recv().await.unwrap()), Some(AuthState::SignedOut) ); + let harnesses = handle + .client() + .call(methods::LIST_HARNESSES, serde_json::json!({})) + .await + .expect("local data RPC is immediately available"); + assert!(harnesses.as_array().is_some_and(|items| !items.is_empty())); + assert!( + !dir.path().join("orgs/dev-org/dev-user").exists(), + "production boot must not create dev-user data" + ); + assert!(dir.path().join("profiles/local").is_dir()); + handle.shutdown().await; + } + + #[tokio::test] + async fn engine_info_is_available_while_cloud_onboarding_is_deferred() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("session.json"), + r#"{"refreshToken":"saved","user":{"id":"user_1","email":"u@example.com"}}"#, + ) + .unwrap(); + let handle = EngineHandle::bootstrap(EngineBootConfig { + data_dir: dir.path().to_path_buf(), + ipc_port: free_port().await, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: Some("client_test".into()), + default_harness: HarnessId::Mock, + }) + .await + .unwrap(); + + let info: EngineInfo = handle + .client() + .call_as(methods::ENGINE_INFO, serde_json::json!({})) + .await + .expect("EngineInfo bypasses deferred cloud stores"); + assert_eq!(info.workspace_scope, WorkspaceScope::Synced); assert!( tokio::time::timeout( std::time::Duration::from_millis(100), @@ -1213,12 +1359,9 @@ mod tests { ) .await .is_err(), - "data RPC must wait behind the production sign-in gate" - ); - assert!( - !dir.path().join("orgs/dev-org/dev-user").exists(), - "production boot must not create dev-user data" + "cloud data waits for organization onboarding" ); + assert!(!dir.path().join("orgs").exists()); handle.shutdown().await; } @@ -1255,6 +1398,10 @@ mod tests { url: format!("ws://127.0.0.1:{port}") } ); + assert_eq!( + handle.engine_info().workspace_scope, + WorkspaceScope::Development + ); let harnesses = handle .client() .call(methods::LIST_HARNESSES, serde_json::json!({})) From 23e2fbe6bd1b07c062fa549f833d0bd229151059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 11:39:08 -0400 Subject: [PATCH 03/18] feat(ui): replace the auth gate with a local sync opt-in --- crates/proto/src/view.rs | 93 ++++- crates/ui/src/settings/devices.rs | 28 +- crates/ui/src/shell.rs | 616 +++++++++++++++++++++++++++--- crates/ui/src/state.rs | 73 +++- 4 files changed, 743 insertions(+), 67 deletions(-) diff --git a/crates/proto/src/view.rs b/crates/proto/src/view.rs index 333a78f5..08c37bd4 100644 --- a/crates/proto/src/view.rs +++ b/crates/proto/src/view.rs @@ -12,7 +12,7 @@ use chrono::{DateTime, Utc}; -use crate::{AuthState, Chat, ChatIndicator, Session, SessionStatus, Space}; +use crate::{AuthState, Chat, ChatIndicator, Session, SessionStatus, Space, WorkspaceScope}; // --------------------------------------------------------------------------- // Connection + status @@ -154,20 +154,97 @@ pub enum GatePhase { Ready, } -/// `auth = None` means "engine doesn't report auth yet" (dev mode) and gates -/// nothing. -pub fn gate_phase(connection: &ConnectionStatus, auth: Option<&AuthState>) -> GatePhase { +/// Missing scope is treated as synced. Current engines always publish +/// [`WorkspaceScope`] before becoming ready, while old daemons are deliberately +/// kept behind the account gate instead of being mistaken for local runtimes. +pub fn gate_phase( + connection: &ConnectionStatus, + workspace_scope: Option, + auth: Option<&AuthState>, +) -> GatePhase { match connection { ConnectionStatus::Connecting => GatePhase::Loading, ConnectionStatus::Failed(err) => GatePhase::Failed(err.clone()), - ConnectionStatus::Ready => match auth { - Some(AuthState::SignedOut) => GatePhase::SignIn, - Some(AuthState::NeedsOrganization { .. }) => GatePhase::OrgGate, - _ => GatePhase::Ready, + ConnectionStatus::Ready => match workspace_scope.unwrap_or(WorkspaceScope::Synced) { + WorkspaceScope::Local | WorkspaceScope::Development => GatePhase::Ready, + WorkspaceScope::Synced => match auth { + Some(AuthState::NeedsOrganization { .. }) => GatePhase::OrgGate, + Some(AuthState::SignedIn { .. }) => GatePhase::Ready, + Some(AuthState::SignedOut) | None => GatePhase::SignIn, + }, }, } } +#[cfg(test)] +mod gate_tests { + use super::*; + use crate::UserProfile; + + fn user() -> UserProfile { + UserProfile { + id: "user-1".into(), + email: "user@example.com".into(), + name: None, + } + } + + #[test] + fn workspace_scope_controls_the_auth_gate() { + assert_eq!( + gate_phase( + &ConnectionStatus::Ready, + Some(WorkspaceScope::Local), + Some(&AuthState::SignedOut), + ), + GatePhase::Ready + ); + assert_eq!( + gate_phase( + &ConnectionStatus::Ready, + Some(WorkspaceScope::Synced), + Some(&AuthState::SignedOut), + ), + GatePhase::SignIn + ); + assert_eq!( + gate_phase( + &ConnectionStatus::Ready, + Some(WorkspaceScope::Synced), + Some(&AuthState::NeedsOrganization { user: user() }), + ), + GatePhase::OrgGate + ); + } + + #[test] + fn development_and_local_never_use_the_workos_gate() { + for scope in [WorkspaceScope::Local, WorkspaceScope::Development] { + for auth in [ + AuthState::SignedOut, + AuthState::NeedsOrganization { user: user() }, + AuthState::SignedIn { + user: user(), + org_id: Some("org-1".into()), + }, + ] { + assert_eq!( + gate_phase(&ConnectionStatus::Ready, Some(scope), Some(&auth)), + GatePhase::Ready + ); + } + } + } + + #[test] + fn missing_scope_falls_back_to_a_synced_gate() { + assert_eq!( + gate_phase(&ConnectionStatus::Ready, None, None), + GatePhase::SignIn + ); + } +} + /// Parse an `AuthStatus` frame tolerantly. The engine currently serializes its /// own enum (`{"_tag": "SignedIn", ...}`) while the proto type expects /// `{"state": "signedIn", ...}` — accept both so either side can converge diff --git a/crates/ui/src/settings/devices.rs b/crates/ui/src/settings/devices.rs index 5fe2ad4b..4c34dc4a 100644 --- a/crates/ui/src/settings/devices.rs +++ b/crates/ui/src/settings/devices.rs @@ -9,6 +9,7 @@ use gpui::{ }; use std::time::Duration; +use comet_proto::WorkspaceScope; use comet_rpc::methods; use crate::composer::{ComposerInput, ComposerInputEvent}; @@ -43,6 +44,16 @@ pub fn format_last_seen(last_seen: Option>, now: DateTime) -> } } +/// Scope-aware copy: a local registry describes only the active local +/// workspace and must not imply that account device metadata is already live. +pub fn devices_subtitle(scope: Option) -> &'static str { + match scope { + Some(WorkspaceScope::Local) => "Manage device details stored in this local workspace.", + Some(WorkspaceScope::Synced) => "Manage device names and inspect synced device metadata.", + Some(WorkspaceScope::Development) | None => "Manage device names for this workspace.", + } +} + struct RenameDialog { device_id: String, input: Entity, @@ -204,9 +215,13 @@ impl Render for DevicesPage { use crate::settings::widgets; let theme = Theme::of(cx).clone(); let now = Utc::now(); - let (devices, local_id) = { + let (devices, local_id, workspace_scope) = { let state = self.state.read(cx); - (state.devices.clone(), state.local_device_id.clone()) + ( + state.devices.clone(), + state.local_device_id.clone(), + state.workspace_scope, + ) }; let copied = self.copied.clone(); let dialog = self.render_rename_dialog(window.viewport_size(), cx); @@ -382,7 +397,7 @@ impl Render for DevicesPage { )) .child(widgets::page_subtitle( &theme, - "Manage device names and inspect synced device metadata.", + devices_subtitle(workspace_scope), )) .when_some(self.error.clone(), |el, message| { el.child( @@ -438,4 +453,11 @@ mod tests { "2d ago" ); } + + #[test] + fn local_subtitle_does_not_claim_synced_metadata() { + let copy = devices_subtitle(Some(WorkspaceScope::Local)); + assert!(copy.contains("local workspace")); + assert!(!copy.contains("synced")); + } } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index eb59ca50..c7c96de1 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -21,6 +21,7 @@ use gpui::{ Task, Window, WindowControlArea, actions, div, prelude::*, px, }; +use comet_proto::{AuthState, WorkspaceScope}; use comet_rpc::methods; use gpui_tokio::Tokio; @@ -406,6 +407,68 @@ enum UpdateFlow { Failed(SharedString), } +/// Account lifecycle owned by this process. Enabling sync never mutates the +/// attached local engine; a new synced runtime is assembled only after quit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SyncFlow { + Idle, + Enabling, + Canceling, + RestartPending { notice_open: bool }, + SignOutConfirm, + SigningOut, + SignedOutRestartRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AccountMenuAction { + EnableSync, + SyncInProgress, + RestartPending, + SignOut, +} + +fn account_menu_action(scope: Option, flow: SyncFlow) -> Option { + match scope { + Some(WorkspaceScope::Local) => match flow { + SyncFlow::Idle => Some(AccountMenuAction::EnableSync), + SyncFlow::Enabling | SyncFlow::Canceling => Some(AccountMenuAction::SyncInProgress), + SyncFlow::RestartPending { .. } => Some(AccountMenuAction::RestartPending), + SyncFlow::SignOutConfirm + | SyncFlow::SigningOut + | SyncFlow::SignedOutRestartRequired => None, + }, + Some(WorkspaceScope::Synced) => match flow { + SyncFlow::SignedOutRestartRequired => None, + _ => Some(AccountMenuAction::SignOut), + }, + Some(WorkspaceScope::Development) | None => None, + } +} + +fn sync_flow_after_auth( + flow: SyncFlow, + scope: Option, + auth: Option<&AuthState>, +) -> SyncFlow { + match scope { + Some(WorkspaceScope::Local) => match (flow, auth) { + (SyncFlow::Enabling, Some(AuthState::SignedIn { .. })) => { + SyncFlow::RestartPending { notice_open: true } + } + _ => flow, + }, + Some(WorkspaceScope::Synced) => match flow { + SyncFlow::SignOutConfirm + | SyncFlow::SigningOut + | SyncFlow::SignedOutRestartRequired => flow, + _ => SyncFlow::Idle, + }, + Some(WorkspaceScope::Development) => SyncFlow::Idle, + None => flow, + } +} + /// The "Create your workspace" gate (feature-inventory §1.2 OrgGate). struct OrgGateUi { name_input: Entity, @@ -490,6 +553,7 @@ pub struct Shell { /// Cached: `detect_install` stats `current_exe` and this renders per frame. install: comet_update::InstallKind, org: Option, + sync_flow: SyncFlow, mutate_task: Option>, auth_task: Option>, /// Kept for the failed-gate "Retry" action. @@ -670,6 +734,7 @@ impl Shell { update_dismissed: None, install: comet_update::detect_install(), org: None, + sync_flow: SyncFlow::Idle, mutate_task: None, auth_task: None, boot, @@ -707,6 +772,16 @@ impl Shell { // ---- splash ---- fn on_state_changed(&mut self, state: &Entity, cx: &mut Context) { + let next_sync_flow = { + let state = state.read(cx); + sync_flow_after_auth(self.sync_flow, state.workspace_scope, state.auth.as_ref()) + }; + if next_sync_flow != self.sync_flow { + self.sync_flow = next_sync_flow; + if matches!(self.sync_flow, SyncFlow::RestartPending { .. }) { + self.org = None; + } + } // Capture knob: the add-space palette needs only the device registry. if self.debug_dialog.as_deref() == Some("add-space") && !state.read(cx).devices.is_empty() { self.debug_dialog = None; @@ -1288,28 +1363,114 @@ impl Shell { cx.notify(); } - fn sign_out(&mut self, cx: &mut Context) { + fn request_sign_out(&mut self, cx: &mut Context) { self.user_menu_open = false; + if self.state.read(cx).workspace_scope != Some(WorkspaceScope::Synced) { + return; + } + self.sync_flow = SyncFlow::SignOutConfirm; + cx.notify(); + } + + fn confirm_sign_out(&mut self, cx: &mut Context) { let Some(engine) = self.state.read(cx).engine().cloned() else { return; }; + self.sync_flow = SyncFlow::SigningOut; self.auth_task = Some(cx.spawn(async move |this, cx| { - if let Err(err) = engine + let result = engine .client() .call(methods::SIGN_OUT, serde_json::json!({})) - .await - { - this.update(cx, |shell, cx| { - shell.sidebar_notice = Some(format!("Sign out failed: {err}").into()); - cx.notify(); - }) - .ok(); + .await; + this.update(cx, |shell, cx| { + match result { + Ok(_) => shell.sync_flow = SyncFlow::SignedOutRestartRequired, + Err(err) => { + shell.sync_flow = SyncFlow::SignOutConfirm; + shell.sidebar_notice = Some(format!("Sign out failed: {err}").into()); + } + } + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } + + fn cancel_auth_setup(&mut self, cx: &mut Context) { + let local = self.state.read(cx).workspace_scope == Some(WorkspaceScope::Local); + let Some(engine) = self.state.read(cx).engine().cloned() else { + return; + }; + let pending_auth = self.auth_task.take(); + let pending_org = self.org.as_mut().and_then(|org| org.task.take()); + if local { + self.sync_flow = SyncFlow::Canceling; + } + self.auth_task = Some(cx.spawn(async move |this, cx| { + // Do not race SignOut against an exchange or organization write + // that can still persist a session after credentials were cleared. + if let Some(task) = pending_auth { + task.await; } + if let Some(task) = pending_org { + task.await; + } + let result = engine + .client() + .call(methods::SIGN_OUT, serde_json::json!({})) + .await; + this.update(cx, |shell, cx| { + match result { + Ok(_) => { + shell.org = None; + if local { + shell.sync_flow = SyncFlow::Idle; + } + } + Err(err) => { + if local { + shell.sync_flow = SyncFlow::Enabling; + } + shell.sidebar_notice = + Some(format!("Could not cancel sign-in: {err}").into()); + } + } + cx.notify(); + }) + .ok(); })); cx.notify(); } + fn postpone_sync_restart(&mut self, cx: &mut Context) { + if matches!(self.sync_flow, SyncFlow::RestartPending { .. }) { + self.sync_flow = SyncFlow::RestartPending { notice_open: false }; + cx.notify(); + } + } + + fn reopen_sync_notice(&mut self, cx: &mut Context) { + self.user_menu_open = false; + if matches!(self.sync_flow, SyncFlow::RestartPending { .. }) { + self.sync_flow = SyncFlow::RestartPending { notice_open: true }; + cx.notify(); + } + } + + fn quit_for_runtime_change(&mut self, cx: &mut Context) { + cx.quit(); + } + fn start_sign_in(&mut self, cx: &mut Context) { + let scope = self.state.read(cx).workspace_scope; + if scope == Some(WorkspaceScope::Development) { + return; + } + self.user_menu_open = false; + if scope == Some(WorkspaceScope::Local) { + self.sync_flow = SyncFlow::Enabling; + } let Some(engine) = self.state.read(cx).engine().cloned() else { return; }; @@ -1323,14 +1484,20 @@ impl Shell { if let Some(url) = value.get("url").and_then(|u| u.as_str()) { cx.open_url(url); } + cx.notify(); } Err(err) => { + if scope == Some(WorkspaceScope::Local) && shell.sync_flow == SyncFlow::Enabling + { + shell.sync_flow = SyncFlow::Idle; + } shell.sidebar_notice = Some(format!("Sign in failed: {err}").into()); cx.notify(); } }) .ok(); })); + cx.notify(); } // ---- org gate ---- @@ -1998,7 +2165,10 @@ impl Shell { /// section (folder + device rows, add-space), the global Active sessions /// list, the notice strip, and the UserMenu (§1.6). fn render_chat_sidebar(&mut self, theme: &Theme, cx: &mut Context) -> AnyElement { - let user = self.state.read(cx).auth_user().cloned(); + let (user, workspace_scope) = { + let state = self.state.read(cx); + (state.auth_user().cloned(), state.workspace_scope) + }; // Keyed rows: (stable key, estimated height, element) — the key + height // list drives the §1.6 resort FLIP diff below (attention-bucket @@ -2067,12 +2237,42 @@ impl Shell { let glass = theme.is_glass(); let sidebar_fade = theme.surface; - let user_line: SharedString = user - .as_ref() - .map(|u| u.name.clone().unwrap_or_else(|| u.email.clone()).into()) - .unwrap_or_else(|| SharedString::from("Not signed in")); - let user_email: Option = user.as_ref().map(|u| u.email.clone().into()); - let user_menu = self.render_user_menu(user_line.clone(), user_email.clone(), theme, cx); + let (user_line, trigger_subline, menu_identity): ( + SharedString, + SharedString, + SharedString, + ) = match workspace_scope { + Some(WorkspaceScope::Local) => { + let line = if matches!(self.sync_flow, SyncFlow::RestartPending { .. }) { + "Sync ready after restart" + } else { + "Local only" + }; + ( + line.into(), + "Stored on this device".into(), + "Stored on this device".into(), + ) + } + Some(WorkspaceScope::Development) => ( + "Development".into(), + "Local development runtime".into(), + "Authentication disabled".into(), + ), + Some(WorkspaceScope::Synced) | None => { + let line: SharedString = user + .as_ref() + .map(|u| u.name.clone().unwrap_or_else(|| u.email.clone()).into()) + .unwrap_or_else(|| SharedString::from("Not signed in")); + let email = user + .as_ref() + .map(|u| SharedString::from(u.email.clone())) + .unwrap_or_else(|| line.clone()); + (line, "Alpha".into(), email) + } + }; + let user_menu = + self.render_user_menu(user_line.clone(), trigger_subline, menu_identity, theme, cx); let spaces_section = self.render_spaces_section(theme, cx); @@ -2333,18 +2533,20 @@ impl Shell { } } - /// UserMenu (§1.6): name/email trigger row; menu with plan badge, Open - /// settings, Sign out. + /// Scope-aware sidebar identity and account menu. Local runtimes advertise + /// their storage boundary and offer sync; synced runtimes offer sign-out. fn render_user_menu( &mut self, user_line: SharedString, - user_email: Option, + trigger_subline: SharedString, + menu_identity: SharedString, theme: &Theme, cx: &mut Context, ) -> AnyElement { let open = self.user_menu_open; - // Bottom-of-sidebar identity (comet user-menu.tsx): avatar circle + - // name with the plan label underneath, Alpha badge chip on the right. + let action = account_menu_action(self.state.read(cx).workspace_scope, self.sync_flow); + // Bottom-of-sidebar identity: avatar circle + scope/account label and + // its secondary status line. let initial: SharedString = user_line .chars() .next() @@ -2421,7 +2623,7 @@ impl Shell { .text_size(px(11.0)) .line_height(px(15.0)) .text_color(theme.text_muted) - .child(SharedString::from("Alpha")), + .child(trigger_subline), ), ); if open { @@ -2429,9 +2631,7 @@ impl Shell { // (exactly as wide as the trigger row — sidebar minus its p-2 // gutters), `flex-col gap-0.5`, then: one small muted email line // (`px-2 pb-1 pt-1.5 text-[11px] text-muted-foreground/70`), - // "Settings", separator, "Sign out". Both rows are plain - // `menuItem`s with muted 16px icons — sign-out carries NO - // destructive tone in the original. + // "Settings", then the action selected by the runtime scope. let menu = popover::popover_card(theme) .w(px(self.settings.sidebar_width - 2.0 * Theme::SPACE_SM)) .on_mouse_down_out(cx.listener(|this, _, _, cx| { @@ -2450,7 +2650,7 @@ impl Shell { .text_size(px(11.0)) .text_color(theme.text_muted.opacity(0.7)) .truncate() - .child(user_email.unwrap_or(user_line)), + .child(menu_identity), ) .child( popover::menu_row(theme, false, "user-menu-settings") @@ -2465,26 +2665,206 @@ impl Shell { ) .child(SharedString::from("Settings")), ) - .child(popover::menu_separator()) - .child( - popover::menu_row(theme, false, "user-menu-signout") - .id("user-menu-signout") - .on_click(cx.listener(|this, _, _, cx| this.sign_out(cx))) - .child( - icon(icons::LOGOUT_2) - .size(px(16.0)) - .text_color(theme.text_muted), - ) - .child(SharedString::from("Sign out")), - ) + .when_some(action, |menu, action| { + let row = match action { + AccountMenuAction::EnableSync => { + popover::menu_row(theme, false, "user-menu-enable-sync") + .id("user-menu-enable-sync") + .on_click(cx.listener(|this, _, _, cx| this.start_sign_in(cx))) + .child( + icon(icons::GLOBAL) + .size(px(16.0)) + .text_color(theme.text_muted), + ) + .child(SharedString::from("Enable sync")) + .into_any_element() + } + AccountMenuAction::SyncInProgress => { + popover::menu_row(theme, false, "user-menu-sync-progress") + .id("user-menu-sync-progress") + .opacity(0.6) + .child( + icon(icons::GLOBAL) + .size(px(16.0)) + .text_color(theme.text_muted), + ) + .child(SharedString::from("Sync setup in progress")) + .into_any_element() + } + AccountMenuAction::RestartPending => { + popover::menu_row(theme, false, "user-menu-sync-restart") + .id("user-menu-sync-restart") + .on_click(cx.listener(|this, _, _, cx| this.reopen_sync_notice(cx))) + .child( + icon(icons::RESTART) + .size(px(16.0)) + .text_color(theme.text_muted), + ) + .child(SharedString::from("Sync ready after restart")) + .into_any_element() + } + AccountMenuAction::SignOut => { + popover::menu_row(theme, false, "user-menu-signout") + .id("user-menu-signout") + .on_click(cx.listener(|this, _, _, cx| this.request_sign_out(cx))) + .child( + icon(icons::LOGOUT_2) + .size(px(16.0)) + .text_color(theme.text_muted), + ) + .child(SharedString::from("Sign out")) + .into_any_element() + } + }; + menu.child(popover::menu_separator()).child(row) + }) .into_any_element(); trigger = trigger.child(popover::anchored_menu_above("user-menu-popover", menu)); } trigger.into_any_element() } - /// Floating layers owned by the shell: the session context menu and the - /// rename / delete-confirm dialogs. + fn render_sync_overlay( + &mut self, + viewport: gpui::Size, + cx: &mut Context, + ) -> Option { + let theme = Theme::of(cx).clone(); + let needs_org = matches!( + self.state.read(cx).auth.as_ref(), + Some(AuthState::NeedsOrganization { .. }) + ); + + if self.sync_flow == SyncFlow::Enabling && needs_org { + return Some(self.render_org_gate(cx)); + } + + let card = match self.sync_flow { + SyncFlow::Enabling => popover::dialog_card(&theme) + .child(popover::dialog_title(&theme, "Enable sync")) + .child( + div().mt(px(6.0)).child(popover::dialog_body( + &theme, + "Finish signing in in your browser. Comet will keep using this local workspace until you quit and reopen.", + )), + ) + .child( + div() + .mt(px(16.0)) + .flex() + .flex_row() + .justify_end() + .gap(px(8.0)) + .child( + popover::btn_ghost(&theme, "Cancel", "sync-enable-cancel") + .id("sync-enable-cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.cancel_auth_setup(cx) + })), + ) + .child( + popover::btn_primary(&theme, "Open browser again") + .id("sync-enable-open-browser") + .on_click(cx.listener(|this, _, _, cx| { + this.start_sign_in(cx) + })), + ), + ) + .into_any_element(), + SyncFlow::Canceling => popover::dialog_card(&theme) + .child(popover::dialog_title(&theme, "Canceling sync setup…")) + .child( + div().mt(px(6.0)).child(popover::dialog_body( + &theme, + "Removing the partial sign-in before returning to your local workspace.", + )), + ) + .into_any_element(), + SyncFlow::RestartPending { notice_open: true } => popover::dialog_card(&theme) + .child(popover::dialog_title( + &theme, + "Sync is ready after restart", + )) + .child( + div().mt(px(6.0)).child(popover::dialog_body( + &theme, + "Quit and reopen Comet to start the synced workspace. Existing local sessions stay on this device and will not be uploaded.", + )), + ) + .child( + div() + .mt(px(16.0)) + .flex() + .flex_row() + .justify_end() + .gap(px(8.0)) + .child( + popover::btn_ghost(&theme, "Later", "sync-restart-later") + .id("sync-restart-later") + .on_click(cx.listener(|this, _, _, cx| { + this.postpone_sync_restart(cx) + })), + ) + .child( + popover::btn_primary(&theme, "Quit Comet") + .id("sync-restart-quit") + .on_click(cx.listener(|this, _, _, cx| { + this.quit_for_runtime_change(cx) + })), + ), + ) + .into_any_element(), + SyncFlow::SignOutConfirm => popover::dialog_card(&theme) + .child(popover::dialog_title(&theme, "Sign out?")) + .child( + div().mt(px(6.0)).child(popover::dialog_body( + &theme, + "Comet must close after removing your credentials so this synced workspace cannot continue with stale account data.", + )), + ) + .child( + div() + .mt(px(16.0)) + .flex() + .flex_row() + .justify_end() + .gap(px(8.0)) + .child( + popover::btn_ghost(&theme, "Cancel", "signout-cancel") + .id("signout-cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.sync_flow = SyncFlow::Idle; + cx.notify(); + })), + ) + .child( + popover::btn_danger(&theme, "Sign out") + .id("signout-confirm") + .on_click(cx.listener(|this, _, _, cx| { + this.confirm_sign_out(cx) + })), + ), + ) + .into_any_element(), + SyncFlow::SigningOut => popover::dialog_card(&theme) + .child(popover::dialog_title(&theme, "Signing out…")) + .child( + div().mt(px(6.0)).child(popover::dialog_body( + &theme, + "Removing account credentials from this device.", + )), + ) + .into_any_element(), + SyncFlow::Idle + | SyncFlow::RestartPending { notice_open: false } + | SyncFlow::SignedOutRestartRequired => return None, + }; + + Some(popover::modal("sync-lifecycle-dialog", viewport, card)) + } + + /// Floating layers owned by the shell: context menus, edit dialogs, and + /// the local-to-synced account lifecycle. fn render_overlays( &mut self, viewport: gpui::Size, @@ -2643,6 +3023,10 @@ impl Shell { overlays.push(popover::modal("delete-chat-dialog", viewport, card)); } + if let Some(sync) = self.render_sync_overlay(viewport, cx) { + overlays.push(sync); + } + overlays } @@ -3159,6 +3543,70 @@ impl Shell { ) } + fn render_signed_out_restart(&mut self, cx: &mut Context) -> AnyElement { + let theme = Theme::of(cx).clone(); + let card = div() + .w(px(380.0)) + .px(px(32.0)) + .py(px(40.0)) + .rounded(px(12.0)) + .border_1() + .border_color(theme.border) + .bg(theme.surface_card) + .shadow_lg() + .flex() + .flex_col() + .items_center() + .text_center() + .child( + icon(icons::COMET_LOGO) + .w(px(31.4)) + .h(px(36.0)) + .text_color(theme.text), + ) + .child( + div() + .mt(px(24.0)) + .text_size(px(18.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme.text) + .child(SharedString::from("Signed out")), + ) + .child( + div() + .mt(px(6.0)) + .mb(px(24.0)) + .text_size(px(13.0)) + .line_height(px(19.0)) + .text_color(theme.text_muted) + .child(SharedString::from( + "Quit and reopen Comet before continuing. This prevents the previous synced workspace from running without its credentials.", + )), + ) + .child( + popover::btn_primary(&theme, "Quit Comet") + .id("signed-out-quit") + .on_click(cx.listener(|this, _, _, cx| this.quit_for_runtime_change(cx))), + ); + + div() + .absolute() + .inset_0() + .occlude() + .bg(theme.bg) + .child(grid_backdrop(&theme)) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child(motion::fade_in("signed-out-restart", card)), + ) + .into_any_element() + } + fn render_gate_card(&mut self, phase: &GatePhase, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); let content: AnyElement = match phase { @@ -3277,11 +3725,12 @@ impl Shell { .into_any_element() } - /// The OrgGate ("Create your workspace"): name form + existing memberships - /// + "Use a different account" (feature-inventory §1.2). + /// Organization onboarding used by the synced gate and, for a local + /// runtime, only after the user explicitly starts the sync opt-in. fn render_org_gate(&mut self, cx: &mut Context) -> AnyElement { self.ensure_org_ui(cx); let theme = Theme::of(cx).clone(); + let local_setup = self.state.read(cx).workspace_scope == Some(WorkspaceScope::Local); let Some(org) = self.org.as_ref() else { return Empty.into_any_element(); }; @@ -3476,14 +3925,19 @@ impl Shell { .text_color(theme.text_muted.opacity(0.6)) .cursor_pointer() .hover(|s| s.text_color(theme.text)) - .on_click(cx.listener(|this, _, _, cx| this.sign_out(cx))) - .child(SharedString::from("Use a different account")), + .on_click(cx.listener(|this, _, _, cx| this.cancel_auth_setup(cx))) + .child(SharedString::from(if local_setup { + "Cancel sync setup" + } else { + "Use a different account" + })), ), ); div() - .size_full() - .relative() + .absolute() + .inset_0() + .occlude() .bg(theme.bg) .child(grid_backdrop(&theme)) .child( @@ -3765,6 +4219,12 @@ impl Render for Shell { // frost paints translucent — the sidebar and card margins read as // glass while the opaque card keeps text off it. let (frost, text, font) = (theme.glass(), theme.text, theme.font_sans.clone()); + let (workspace_scope, auth) = { + let state = self.state.read(cx); + (state.workspace_scope, state.auth.clone()) + }; + self.sync_flow = sync_flow_after_auth(self.sync_flow, workspace_scope, auth.as_ref()); + let restart_required = self.sync_flow == SyncFlow::SignedOutRestartRequired; let gate = self .debug_gate .clone() @@ -3803,7 +4263,8 @@ impl Render for Shell { } })); } - if matches!(gate, GatePhase::Ready) + if !restart_required + && matches!(gate, GatePhase::Ready) && matches!(self.route, Route::Chat) && window.focused(cx).is_none() { @@ -3847,7 +4308,12 @@ impl Render for Shell { } })); - let root = match &gate { + let render_gate = if restart_required { + GatePhase::Loading + } else { + gate.clone() + }; + let root = match &render_gate { GatePhase::Ready => { // Focus is a sync signal: on the rising edge of window // activation, nudge every open room to verify liveness — a @@ -4025,6 +4491,12 @@ impl Render for Shell { root.child(card) } }; + let root = if restart_required { + let restart = self.render_signed_out_restart(cx); + root.child(restart) + } else { + root + }; // A manually-driven tween is mid-flight: keep frames coming (the same // scheduling `with_animation` would have requested). Hover color fades @@ -4053,7 +4525,9 @@ impl Render for Shell { // keep them above the splash and every auth/org/error gate as well as // the full application. Gate pages also need a native drag surface // because they do not render the unified tabs/settings titlebar. - let root = if matches!(gate, GatePhase::Ready) || !cfg!(target_os = "windows") { + let root = if (!restart_required && matches!(gate, GatePhase::Ready)) + || !cfg!(target_os = "windows") + { root } else { root.child( @@ -4074,6 +4548,50 @@ impl Render for Shell { mod tests { use super::*; + #[test] + fn account_actions_follow_the_attached_workspace_scope() { + assert_eq!( + account_menu_action(Some(WorkspaceScope::Local), SyncFlow::Idle), + Some(AccountMenuAction::EnableSync) + ); + assert_eq!( + account_menu_action(Some(WorkspaceScope::Synced), SyncFlow::Idle), + Some(AccountMenuAction::SignOut) + ); + assert_eq!( + account_menu_action(Some(WorkspaceScope::Development), SyncFlow::Idle), + None + ); + } + + #[test] + fn local_sign_in_waits_for_a_new_runtime_before_syncing() { + let signed_in = AuthState::SignedIn { + user: comet_proto::UserProfile { + id: "user-1".into(), + email: "user@example.com".into(), + name: None, + }, + org_id: Some("org-1".into()), + }; + + assert_eq!( + sync_flow_after_auth( + SyncFlow::Enabling, + Some(WorkspaceScope::Local), + Some(&signed_in), + ), + SyncFlow::RestartPending { notice_open: true } + ); + assert_eq!( + account_menu_action( + Some(WorkspaceScope::Local), + SyncFlow::RestartPending { notice_open: false }, + ), + Some(AccountMenuAction::RestartPending) + ); + } + #[test] fn titlebar_cluster_matches_comet_window_controls() { // comet window-controls.tsx: `left: fullscreen ? 12 : 88` — the diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index 0685477e..fcce7fd9 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -431,6 +431,9 @@ pub const PENDING_SEND_TTL_MS: i64 = 30_000; /// glue ([`Self::bootstrap`], [`Self::select_chat`]) layers subscriptions on top. pub struct AppState { pub connection: ConnectionStatus, + /// Fixed data boundary of the attached engine. Authentication may change + /// in place, but changing this scope requires assembling a new runtime. + pub workspace_scope: Option, /// Auth stream value; `None` until the engine reports one (M4). pub auth: Option, pub devices: Vec, @@ -476,6 +479,7 @@ impl AppState { pub fn new() -> Self { Self { connection: ConnectionStatus::Connecting, + workspace_scope: None, auth: None, devices: Vec::new(), spaces: Vec::new(), @@ -774,7 +778,7 @@ impl AppState { } pub fn gate(&self) -> GatePhase { - gate_phase(&self.connection, self.auth.as_ref()) + gate_phase(&self.connection, self.workspace_scope, self.auth.as_ref()) } pub fn engine(&self) -> Option<&EngineHandle> { @@ -789,6 +793,8 @@ impl AppState { let data_dir = config.data_dir.clone(); state.update(cx, |s, cx| { s.connection = ConnectionStatus::Connecting; + s.workspace_scope = None; + s.auth = None; s.data_dir = Some(data_dir); cx.notify(); }); @@ -818,7 +824,9 @@ impl AppState { /// Methods the engine doesn't serve yet (chats/devices/auth land with the /// workspace doc in M4) fail their subscribe and are skipped gracefully. fn attach_engine(&mut self, handle: EngineHandle, cx: &mut Context) { - self.connection = ConnectionStatus::Ready; + let engine_info = handle.engine_info(); + self.workspace_scope = Some(engine_info.workspace_scope); + self.local_device_id = Some(engine_info.device_id.clone()); self.engine = Some(handle.clone()); self.watch_tasks = vec![ spawn_watch( @@ -855,6 +863,9 @@ impl AppState { ), spawn_local_device_probe(cx, handle.clone()), ]; + // EngineInfo is part of the attachment boundary: views must know which + // data profile they reached before they are allowed to render Ready. + self.connection = ConnectionStatus::Ready; // Re-subscribe the transcript if a chat was already selected (reconnect path). if let Some(chat_id) = self.selected_chat.clone() { self.transcript_task = Some(spawn_transcript_watch(cx, handle, chat_id)); @@ -1175,6 +1186,14 @@ mod tests { assert_eq!(info.device_id, "legacy-device"); assert_eq!(info.workspace_scope, WorkspaceScope::Synced); + assert_eq!( + gate_phase( + &ConnectionStatus::Ready, + Some(info.workspace_scope), + Some(&AuthState::SignedOut), + ), + GatePhase::SignIn + ); } #[tokio::test] @@ -1832,22 +1851,33 @@ mod tests { name: None, }; assert_eq!( - gate_phase(&ConnectionStatus::Connecting, None), + gate_phase(&ConnectionStatus::Connecting, None, None), GatePhase::Loading ); assert_eq!( - gate_phase(&ConnectionStatus::Failed("boom".into()), None), + gate_phase(&ConnectionStatus::Failed("boom".into()), None, None), GatePhase::Failed("boom".into()) ); - // Unknown auth (pre-M4) gates nothing. - assert_eq!(gate_phase(&ConnectionStatus::Ready, None), GatePhase::Ready); assert_eq!( - gate_phase(&ConnectionStatus::Ready, Some(&AuthState::SignedOut)), + gate_phase( + &ConnectionStatus::Ready, + Some(WorkspaceScope::Local), + Some(&AuthState::SignedOut), + ), + GatePhase::Ready + ); + assert_eq!( + gate_phase( + &ConnectionStatus::Ready, + Some(WorkspaceScope::Synced), + Some(&AuthState::SignedOut), + ), GatePhase::SignIn ); assert_eq!( gate_phase( &ConnectionStatus::Ready, + Some(WorkspaceScope::Synced), Some(&AuthState::SignedIn { user: user.clone(), org_id: None @@ -1859,12 +1889,41 @@ mod tests { assert_eq!( gate_phase( &ConnectionStatus::Ready, + Some(WorkspaceScope::Synced), Some(&AuthState::NeedsOrganization { user }) ), GatePhase::OrgGate ); } + #[test] + fn auth_changes_do_not_change_a_local_runtime_scope_or_watches() { + let mut state = AppState::new(); + state.workspace_scope = Some(WorkspaceScope::Local); + state.watch_tasks.push(Task::ready(())); + + state.apply_auth(AuthState::NeedsOrganization { + user: UserProfile { + id: "u".into(), + email: "w@example.com".into(), + name: None, + }, + }); + assert_eq!(state.workspace_scope, Some(WorkspaceScope::Local)); + assert_eq!(state.watch_tasks.len(), 1); + + state.apply_auth(AuthState::SignedIn { + user: UserProfile { + id: "u".into(), + email: "w@example.com".into(), + name: None, + }, + org_id: Some("org-1".into()), + }); + assert_eq!(state.workspace_scope, Some(WorkspaceScope::Local)); + assert_eq!(state.watch_tasks.len(), 1); + } + #[test] fn auth_frames_parse_both_wire_shapes() { // Proto shape. From 810633a3df5fa9b9d9982f988fd688f99d9ce9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 11:49:05 -0400 Subject: [PATCH 04/18] feat(cli): make local-only the default daemon lifecycle --- apps/comet/src/auth_cli.rs | 233 +++++++++++++++++++++++++++++++------ apps/comet/src/daemon.rs | 24 ++-- apps/comet/src/main.rs | 33 +++--- edge/src/install.sh | 39 +++---- 4 files changed, 249 insertions(+), 80 deletions(-) diff --git a/apps/comet/src/auth_cli.rs b/apps/comet/src/auth_cli.rs index 05e665a9..c572ff37 100644 --- a/apps/comet/src/auth_cli.rs +++ b/apps/comet/src/auth_cli.rs @@ -10,12 +10,77 @@ use std::io::IsTerminal; -use comet_engine::{AuthState, Engine, EngineConfig, InstanceLock}; +use comet_engine::{AuthState, Engine, EngineConfig, InstanceLock, WorkspaceScope}; + +#[derive(Debug, PartialEq, Eq)] +struct AccountStatus { + mode: &'static str, + auth: String, + healthy: bool, +} + +fn account_status(scope: WorkspaceScope, auth: &AuthState) -> AccountStatus { + match scope { + WorkspaceScope::Local => match auth { + AuthState::SignedOut => AccountStatus { + mode: "local only", + auth: "signed out (optional in local-only mode)".into(), + healthy: true, + }, + AuthState::NeedsOrganization { user } => AccountStatus { + mode: "local only", + auth: format!( + "signed in as {}; finish workspace setup to enable sync", + user.email + ), + healthy: true, + }, + AuthState::SignedIn { user, .. } => AccountStatus { + mode: "local only", + auth: format!("signed in as {}; sync is ready after restart", user.email), + healthy: true, + }, + }, + WorkspaceScope::Development => AccountStatus { + mode: "development", + auth: "dev mode (bearer = user id)".into(), + healthy: true, + }, + WorkspaceScope::Synced => match auth { + AuthState::SignedIn { user, org_id } => AccountStatus { + mode: "synced", + auth: format!( + "signed in as {}{}", + user.email, + org_id + .as_ref() + .map(|org| format!(" (workspace {org})")) + .unwrap_or_default() + ), + healthy: true, + }, + AuthState::NeedsOrganization { user } => AccountStatus { + mode: "synced", + auth: format!( + "signed in as {} but no workspace selected — run `comet login`", + user.email + ), + healthy: false, + }, + AuthState::SignedOut => AccountStatus { + mode: "synced", + auth: "saved session is no longer valid — run `comet login`".into(), + healthy: false, + }, + }, + } +} /// `comet login`: authenticate via the paste-code flow (and workspace /// onboarding), persist `session.json`, and exit. pub async fn login(config: EngineConfig) -> anyhow::Result<()> { std::fs::create_dir_all(&config.data_dir)?; + let _lock = engine_lock(&config, "sign in")?; let auth = Engine::build_auth(&config).await; if !auth.workos_enabled() { println!("Auth is in dev mode (no WorkOS client id) — there is nothing to sign in to."); @@ -30,12 +95,12 @@ pub async fn login(config: EngineConfig) -> anyhow::Result<()> { .unwrap_or_default() ); println!("Run `comet logout` first to switch accounts."); + println!("The next engine start will use the synced workspace."); return Ok(()); } if !std::io::stdin().is_terminal() { anyhow::bail!("comet login needs an interactive terminal"); } - let _lock = engine_lock(&config, "sign in")?; comet_engine::terminal_sign_in(&auth).await?; match auth.state() { AuthState::SignedIn { user, org_id } => { @@ -46,7 +111,9 @@ pub async fn login(config: EngineConfig) -> anyhow::Result<()> { .map(|org| format!(" (workspace {org})")) .unwrap_or_default() ); - println!("Session saved — `comet headless` (and the daemon) will use it."); + println!( + "Sync is ready. Start or restart Comet to open the synced workspace; existing local sessions will stay local." + ); } // terminal_sign_in only returns Ok once signed in; keep an honest fallback. _ => println!("Sign-in did not complete."), @@ -57,17 +124,22 @@ pub async fn login(config: EngineConfig) -> anyhow::Result<()> { /// `comet logout`: remove the persisted session. pub async fn logout(config: EngineConfig) -> anyhow::Result<()> { std::fs::create_dir_all(&config.data_dir)?; - let auth = Engine::build_auth(&config).await; let _lock = engine_lock(&config, "sign out")?; + let auth = Engine::build_auth(&config).await; if !auth.workos_enabled() { // Dev mode has no live session, but clear any stale session.json from a // previous WorkOS-mode run so the next real run starts signed out. auth.sign_out(); - println!("Auth is in dev mode — cleared any saved session."); + println!("Auth is in dev mode — cleared any stale saved session."); + println!("The next engine start will remain in development mode."); return Ok(()); } match auth.state() { - AuthState::SignedOut => println!("No saved session."), + AuthState::SignedOut => { + // Also remove malformed or stale files that could not be loaded. + auth.sign_out(); + println!("No valid saved session; cleared any stale session file."); + } state => { let email = state .user() @@ -80,42 +152,23 @@ pub async fn logout(config: EngineConfig) -> anyhow::Result<()> { ); } } + println!("The next engine start will use the local-only workspace."); Ok(()) } -/// `comet status`: report auth + engine liveness. Exits nonzero when a sign-in -/// is needed, so scripts (and service health checks) can gate on it. +/// `comet status`: report the fixed scope a new engine would select, optional +/// auth, and engine liveness. Local-only is a healthy signed-out state. pub async fn status(config: EngineConfig) -> anyhow::Result<()> { let auth = Engine::build_auth(&config).await; + let next_scope = Engine::initial_workspace_scope(&auth); + let scope = live_engine_scope(config.ipc_port) + .await + .unwrap_or(next_scope); + let account = account_status(scope, &auth.state()); println!("Data dir: {}", config.data_dir.display()); println!("Edge: {}", config.edge_url); - let signed_in = match (auth.workos_enabled(), auth.state()) { - (false, _) => { - println!("Auth: dev mode (bearer = user id)"); - true - } - (true, AuthState::SignedIn { user, org_id }) => { - println!( - "Auth: signed in as {}{}", - user.email, - org_id - .map(|org| format!(" (workspace {org})")) - .unwrap_or_default() - ); - true - } - (true, AuthState::NeedsOrganization { user }) => { - println!( - "Auth: signed in as {} but no workspace selected — run `comet login`", - user.email - ); - false - } - (true, AuthState::SignedOut) => { - println!("Auth: signed out — run `comet login`"); - false - } - }; + println!("Mode: {}", account.mode); + println!("Auth: {}", account.auth); match InstanceLock::holder(&config.data_dir) { Some(pid) => println!("Engine: running (pid {pid})"), None => println!("Engine: not running"), @@ -131,12 +184,28 @@ pub async fn status(config: EngineConfig) -> anyhow::Result<()> { }, config.ipc_port ); - if !signed_in { + if !account.healthy { std::process::exit(1); } Ok(()) } +/// Prefer the immutable scope of a live runtime. Falling back to the next-boot +/// derivation is correct when no engine is listening and tolerant of old +/// daemons that predate EngineInfo. +async fn live_engine_scope(ipc_port: u16) -> Option { + let client = comet_rpc::connect_ws(&format!("ws://127.0.0.1:{ipc_port}")) + .await + .ok()?; + let value = client + .call(comet_rpc::methods::ENGINE_INFO, serde_json::json!({})) + .await + .ok()?; + serde_json::from_value::(value) + .ok() + .map(|info| info.workspace_scope) +} + /// The same exclusive data-dir lock the engine holds for its lifetime: taken for /// the whole login/logout mutation so we never rotate or delete a session out /// from under a running engine (whose in-memory copy would fight back — the next @@ -149,3 +218,95 @@ fn engine_lock(config: &EngineConfig, verb: &str) -> anyhow::Result EngineConfig { + EngineConfig { + data_dir: data_dir.to_path_buf(), + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + ipc_port: 0, + default_harness: HarnessId::Mock, + org_id: None, + workos_client_id: Some("client_test".into()), + } + } + + #[test] + fn signed_out_local_status_is_healthy() { + let status = account_status(WorkspaceScope::Local, &AuthState::SignedOut); + assert_eq!(status.mode, "local only"); + assert_eq!(status.auth, "signed out (optional in local-only mode)"); + assert!(status.healthy); + } + + #[test] + fn synced_status_still_requires_a_complete_account() { + let user = AuthUser { + id: "user-1".into(), + email: "user@example.com".into(), + name: None, + }; + assert!( + !account_status( + WorkspaceScope::Synced, + &AuthState::NeedsOrganization { user: user.clone() }, + ) + .healthy + ); + assert!( + account_status( + WorkspaceScope::Synced, + &AuthState::SignedIn { + user, + org_id: Some("org-1".into()), + }, + ) + .healthy + ); + } + + #[cfg(unix)] + #[test] + fn login_and_logout_lock_out_a_running_engine() { + let dir = tempfile::tempdir().unwrap(); + let config = config(dir.path()); + let _engine = InstanceLock::acquire(dir.path()).unwrap(); + + for verb in ["sign in", "sign out"] { + let error = engine_lock(&config, verb).unwrap_err().to_string(); + assert!(error.contains("while an engine is running")); + assert!(error.contains("stop it first")); + } + } + + #[tokio::test] + async fn logout_makes_the_next_engine_start_local() { + let dir = tempfile::tempdir().unwrap(); + let config = config(dir.path()); + let session = dir.path().join("session.json"); + std::fs::write( + &session, + r#"{"refreshToken":"refresh-1","user":{"id":"user-1","email":"user@example.com"},"orgId":"org-1"}"#, + ) + .unwrap(); + let before = Engine::build_auth(&config).await; + assert_eq!( + Engine::initial_workspace_scope(&before), + WorkspaceScope::Synced + ); + + logout(config.clone()).await.unwrap(); + + assert!(!session.exists()); + let after = Engine::build_auth(&config).await; + assert_eq!( + Engine::initial_workspace_scope(&after), + WorkspaceScope::Local + ); + } +} diff --git a/apps/comet/src/daemon.rs b/apps/comet/src/daemon.rs index eb80586c..c2bb13e3 100644 --- a/apps/comet/src/daemon.rs +++ b/apps/comet/src/daemon.rs @@ -4,9 +4,8 @@ //! `COMET_*` environment captured at install time, so //! `COMET_EDGE_URL=… comet daemon install` bakes that override in. //! -//! Auth is decoupled: the service loads the session `comet login` persisted and -//! exits with "run `comet login` first" otherwise (`terminal_sign_in`'s non-TTY -//! path) — it never waits interactively for OAuth. +//! Auth is decoupled: without a saved session the service remains up on the +//! local-only profile. `comet login` and a service restart opt into sync. use std::path::{Path, PathBuf}; use std::process::Command; @@ -70,6 +69,9 @@ pub fn install(data_dir: &Path) -> anyhow::Result<()> { } else { bail!("comet daemon is only supported on macOS (launchd) and Linux (systemd)"); } + println!( + "Without a saved account the engine stays local-only; sign-in and restart are optional for sync." + ); println!( "Logs: {}", if cfg!(target_os = "macos") { @@ -223,12 +225,8 @@ fn captured_env() -> Vec<(String, String)> { } fn render_systemd_unit(exe: &Path, env: &[(String, String)]) -> String { - // The start limit must actually trip on the "run `comet login` first" - // fail-fast exit (5 × RestartSec=5 lands inside the 60s window) — otherwise - // a signed-out daemon restart-loops forever. let mut unit = String::from( - "[Unit]\nDescription=Comet native headless engine\nAfter=network-online.target\n\ - StartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\n", + "[Unit]\nDescription=Comet native headless engine\nAfter=network-online.target\n\n[Service]\n", ); for (key, value) in env { // systemd unquotes the value; escape the characters it treats specially. @@ -395,10 +393,20 @@ mod tests { // Inner quotes escaped so systemd re-parses the value verbatim. assert!(unit.contains("Environment=\"RUST_LOG=info,comet=\\\"debug\\\"\"\n")); assert!(unit.contains("Restart=on-failure")); + assert!(!unit.contains("session.json")); + assert!(!unit.contains("ConditionPathExists")); assert!(unit.contains("EnvironmentFile=-%h/.comet-native/env")); assert!(unit.contains("WantedBy=default.target")); } + #[test] + fn curl_installer_always_starts_the_local_capable_service() { + let installer = include_str!("../../../edge/src/install.sh"); + assert!(!installer.contains("session.json")); + assert!(installer.contains("systemctl --user enable comet-native")); + assert!(installer.contains("systemctl --user restart comet-native")); + } + #[test] fn installed_exe_uses_the_current_symlink() { // Installer-managed binary (current_exe resolves the `current` symlink to diff --git a/apps/comet/src/main.rs b/apps/comet/src/main.rs index afbef1be..979f28a1 100644 --- a/apps/comet/src/main.rs +++ b/apps/comet/src/main.rs @@ -1,6 +1,6 @@ -//! comet — headed by default; `comet headless` runs the engine alone. Auth is -//! decoupled from the daemon: `comet login` persists the session and exits, so a -//! service-managed `comet headless` only ever loads saved credentials. +//! comet — headed by default; `comet headless` runs the engine alone. Both start +//! local-only without credentials. `comet login` and `comet logout` select the +//! profile used by the next engine start without mutating a live runtime. mod auth_cli; mod daemon; @@ -17,13 +17,13 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Run the engine without a UI (VPS / remote device mode). + /// Run the engine without a UI (local-only unless a saved session enables sync). Headless, - /// Sign in (paste-code flow), persist the session, and exit. + /// Sign in and enable sync on the next engine start. Login, - /// Remove the saved session. + /// Remove the saved session and return to local-only on the next start. Logout, - /// Show auth + engine status (exits nonzero when a sign-in is needed). + /// Show workspace mode, optional auth, and engine status. Status, /// Live sync introspection from the running engine: per-room connection /// state, last pushed-frame/ack ages, rejoin/probe/resync counters. @@ -76,8 +76,8 @@ fn edge_url_from_env() -> String { /// WorkOS client id resolution: explicit env wins (empty string = dev mode); /// otherwise a `COMET_EDGE_TOKEN` dev bearer keeps dev mode (smoke tests, -/// local wrangler); otherwise the baked production client id — so a bare -/// `comet headless` signs in against production with zero configuration. +/// local wrangler); otherwise the baked production client id makes optional +/// sync available while a bare start remains local-only. fn workos_client_id_from_env(edge_token: &Option) -> Option { match std::env::var("COMET_WORKOS_CLIENT_ID") { Ok(v) if v.trim().is_empty() => None, @@ -371,8 +371,10 @@ fn open_log_file_in(dir: &std::path::Path, mode: &str) -> Option let rc = unsafe { libc::flock(existing.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; if rc != 0 { // A live process owns the canonical log — leave it alone. - return std::fs::File::create(dir.join(format!("comet-{mode}.{}.log", std::process::id()))) - .ok(); + return std::fs::File::create( + dir.join(format!("comet-{mode}.{}.log", std::process::id())), + ) + .ok(); } // No live writer: rotate, create fresh, and lock it as ours. (The // probe's flock dies with `existing`; a first-ever launch has nothing @@ -417,7 +419,10 @@ mod log_file_tests { // After the owner exits, a fresh launch rotates normally. drop(first); let third = open_log_file_in(dir, "headed").expect("third log"); - assert!(dir.join("comet-headed.log.old").is_file(), "rotation resumes"); + assert!( + dir.join("comet-headed.log.old").is_file(), + "rotation resumes" + ); drop(third); } } @@ -426,7 +431,9 @@ mod log_file_tests { /// only exist when a second instance raced a live one for the canonical log. #[cfg(unix)] fn sweep_stale_pid_logs(dir: &std::path::Path, mode: &str) { - let Ok(entries) = std::fs::read_dir(dir) else { return }; + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; let prefix = format!("comet-{mode}."); let week = std::time::Duration::from_secs(7 * 24 * 60 * 60); for entry in entries.flatten() { diff --git a/edge/src/install.sh b/edge/src/install.sh index fcd3f692..8f90312f 100644 --- a/edge/src/install.sh +++ b/edge/src/install.sh @@ -4,8 +4,9 @@ # curl -fsSL https://comet.zeron.sh/install.sh | sh # # Installs the self-contained native binary (no runtime deps) to -# ~/.comet-native/app, puts `comet` on PATH, and — once you've signed in — -# runs it as a systemd user service that survives reboots. Re-running +# ~/.comet-native/app, puts `comet` on PATH, and runs it as a local-only +# systemd user service that survives reboots. Signing in is optional and +# enables sync after a restart. Re-running # upgrades in place; ~/.comet-native state is preserved. # # The binary ships with production endpoints baked in: no COMET_EDGE_URL or @@ -62,11 +63,8 @@ mkdir -p "$HOME/.local/bin" ln -sf "$app_root/current/comet" "$HOME/.local/bin/comet" # --- service ----------------------------------------------------------------- -# Auth is decoupled from the daemon: `comet login` persists the session and a -# service-managed `comet headless` loads it (exiting with "run comet login -# first" otherwise) — so the service starts only after first sign-in. -signed_in=no -[ -f "$data_root/session.json" ] && signed_in=yes +# The daemon is useful before auth: without a saved session it serves the local +# profile. Login only changes which profile the next daemon start selects. service=manual if command -v systemctl >/dev/null 2>&1 && [ -n "${XDG_RUNTIME_DIR:-}" ]; then @@ -75,8 +73,6 @@ if command -v systemctl >/dev/null 2>&1 && [ -n "${XDG_RUNTIME_DIR:-}" ]; then [Unit] Description=Comet native headless engine After=network-online.target -StartLimitIntervalSec=60 -StartLimitBurst=5 [Service] ExecStart=%h/.comet-native/app/current/comet headless @@ -88,13 +84,9 @@ EnvironmentFile=-%h/.comet-native/env WantedBy=default.target UNIT systemctl --user daemon-reload - systemctl --user enable comet-native >/dev/null 2>&1 || true - if [ "$signed_in" = yes ]; then - systemctl --user restart comet-native - service=running - else - service=ready - fi + systemctl --user enable comet-native + systemctl --user restart comet-native + service=running # Keep the user manager (and the engine) running without an active login. loginctl enable-linger "$USER" 2>/dev/null \ || sudo -n loginctl enable-linger "$USER" 2>/dev/null \ @@ -117,15 +109,16 @@ echo "✓ comet $ver installed$path_hint" echo "" case "$service" in running) - echo "the engine restarted with the new version." + echo "the engine is running with the new version (local-only unless sync is enabled)." echo " systemctl --user status comet-native check the service" - ;; - ready) - echo "next steps:" - echo " comet login sign in (paste-code) and exit" - echo " systemctl --user start comet-native then start the engine" + echo "" + echo "optional sync (local sessions stay local):" + echo " systemctl --user stop comet-native" + echo " comet login" + echo " systemctl --user restart comet-native" ;; manual) - echo "next: \`comet login\` to sign in, then run the engine with \`comet headless\`." + echo "next: run the local-only engine with \`comet headless\`." + echo "optional sync: run \`comet login\` before starting the engine." ;; esac From 1002f70778daf6d9fcfd3ac396b01eac1f73143d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 12:00:15 -0400 Subject: [PATCH 05/18] test(engine): lock down local and synced profile isolation --- crates/engine/tests/local_profiles.rs | 441 ++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 crates/engine/tests/local_profiles.rs diff --git a/crates/engine/tests/local_profiles.rs b/crates/engine/tests/local_profiles.rs new file mode 100644 index 00000000..58ac3aab --- /dev/null +++ b/crates/engine/tests/local_profiles.rs @@ -0,0 +1,441 @@ +//! Integrated regressions for local-first profile privacy and lifecycle boundaries. + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use comet_engine::{ + AuthState, Engine, EngineConfig, EngineCore, EngineProfile, HarnessId, WorkspaceScope, + default_registry, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +fn config( + data_dir: &Path, + edge_url: String, + workos_client_id: Option<&str>, + edge_token: Option<&str>, +) -> EngineConfig { + EngineConfig { + data_dir: data_dir.to_path_buf(), + edge_url, + edge_token: edge_token.map(str::to_string), + ipc_port: 0, + default_harness: HarnessId::Mock, + org_id: None, + workos_client_id: workos_client_id.map(str::to_string), + } +} + +fn assemble(profile: EngineProfile) -> EngineCore { + EngineCore::assemble_with_profile(profile, Arc::new(default_registry()), HarnessId::Mock, None) + .expect("assemble profile") +} + +async fn shutdown(core: EngineCore) { + core.shutdown().await; + drop(core); +} + +#[tokio::test] +async fn local_and_synced_profiles_remain_isolated_across_restarts() { + let dir = tempfile::tempdir().expect("tempdir"); + let local_profile = EngineProfile::local(dir.path()).expect("local profile"); + let local_user_id = local_profile.user_id().to_string(); + let local_profile_file = dir.path().join("local-profile.json"); + let local_profile_bytes = std::fs::read(&local_profile_file).expect("local profile file"); + + let local_upload = { + let core = assemble(local_profile.clone()); + let device_id = core.device_id.clone(); + core.workspace + .create_space( + "local-space", + &device_id, + "/private/local-project", + Some("Private project".into()), + false, + ) + .expect("create local space"); + core.workspace + .create_chat("local-chat", "local-space", None, None) + .expect("create local chat"); + core.workspace + .rename_chat("local-chat", "Private local session") + .expect("name local chat"); + core.doc_host + .open("local-chat") + .expect("open local chat doc") + .write_user_message("local-message", "Private local transcript", 1) + .expect("write local transcript"); + core.uploads + .append("local-upload", "cHJpdmF0ZQ==", Some(0)) + .expect("stage local upload"); + let upload = core + .uploads + .commit("local-upload", "private.png") + .expect("commit local upload"); + assert!(Path::new(&upload).starts_with(local_profile.uploads_root())); + shutdown(core).await; + (device_id, upload) + }; + + let synced_profile = EngineProfile::synced(dir.path(), "cloud-org", "cloud-user"); + { + let core = assemble(synced_profile.clone()); + assert_eq!(core.device_id, local_upload.0, "device identity is global"); + assert!( + core.workspace + .chat("local-chat") + .expect("read synced chats") + .is_none(), + "the synced profile must not expose local chats" + ); + assert_eq!(core.uploads.dir(), dir.path().join("uploads")); + let error = core + .uploads + .read_chunk(&local_upload.1, 0, &[]) + .expect_err("synced upload jail must reject a local-profile path"); + assert!( + error.to_string().contains("outside the upload cache"), + "unexpected jail error: {error}" + ); + + core.workspace + .create_space( + "synced-space", + &core.device_id, + "/shared/cloud-project", + None, + false, + ) + .expect("create synced space"); + core.workspace + .create_chat("synced-chat", "synced-space", None, None) + .expect("create synced chat"); + shutdown(core).await; + } + + let reopened_profile = EngineProfile::local(dir.path()).expect("reopen local profile"); + assert_eq!(reopened_profile.user_id(), local_user_id); + assert_eq!( + std::fs::read(&local_profile_file).expect("re-read local profile"), + local_profile_bytes, + "reopening local must not rotate or rewrite its identity" + ); + { + let core = assemble(reopened_profile); + assert_eq!(core.device_id, local_upload.0); + let chat = core + .workspace + .chat("local-chat") + .expect("read local chat") + .expect("local chat survived restart"); + assert_eq!(chat.title.as_deref(), Some("Private local session")); + assert_eq!(chat.cwd.as_deref(), Some("/private/local-project")); + let transcript = core + .doc_host + .open("local-chat") + .expect("reopen local chat doc") + .doc() + .read_entries() + .expect("read local transcript"); + assert_eq!(transcript.len(), 1); + assert_eq!(transcript[0].id, "local-message"); + assert!( + core.workspace + .chat("synced-chat") + .expect("read local chats") + .is_none(), + "the local profile must not expose synced chats" + ); + assert_eq!( + core.uploads + .read_chunk(&local_upload.1, 0, &[]) + .expect("local profile can read its upload") + .data, + "cHJpdmF0ZQ==" + ); + shutdown(core).await; + } +} + +#[tokio::test] +async fn existing_session_opens_the_historical_cloud_layout_in_place() { + let dir = tempfile::tempdir().expect("tempdir"); + let historical = EngineProfile::synced(dir.path(), "legacy-org", "legacy-user"); + { + let core = assemble(historical.clone()); + core.workspace + .create_space( + "legacy-space", + &core.device_id, + "/shared/legacy-project", + None, + false, + ) + .expect("create historical space"); + core.workspace + .create_chat("legacy-chat", "legacy-space", None, None) + .expect("create historical chat"); + shutdown(core).await; + } + std::fs::write( + dir.path().join("session.json"), + r#"{"refreshToken":"refresh-1","user":{"id":"legacy-user","email":"legacy@example.com"},"orgId":"legacy-org"}"#, + ) + .expect("seed existing session"); + + let config = config( + dir.path(), + "http://127.0.0.1:1".into(), + Some("client_test"), + None, + ); + let auth = Engine::build_auth(&config).await; + let scope = Engine::initial_workspace_scope(&auth); + let resolved = Engine::resolve_profile(&config, &auth, scope) + .expect("resolve existing session") + .expect("existing org is ready"); + + assert_eq!(scope, WorkspaceScope::Synced); + assert_eq!(resolved, historical); + assert_eq!( + resolved.store_root(), + dir.path().join("orgs/legacy-org/legacy-user") + ); + assert!(!dir.path().join("profiles/synced").exists()); + { + let core = assemble(resolved); + assert!( + core.workspace + .chat("legacy-chat") + .expect("read historical layout") + .is_some(), + "the existing cloud store must open without migration" + ); + shutdown(core).await; + } + assert!(!dir.path().join("profiles/synced").exists()); +} + +struct RecordingEdge { + url: String, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for RecordingEdge { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl RecordingEdge { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind recording edge"); + let url = format!("http://{}", listener.local_addr().expect("edge addr")); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = requests.clone(); + let task = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let seen = seen.clone(); + tokio::spawn(async move { handle_edge_request(stream, seen).await }); + } + }); + Self { + url, + requests, + task, + } + } +} + +async fn handle_edge_request(mut stream: tokio::net::TcpStream, requests: Arc>>) { + let Some((target, _body)) = read_request(&mut stream).await else { + return; + }; + requests.lock().expect("requests lock").push(target.clone()); + let path = target.split('?').next().unwrap_or(""); + let (status, body) = if path == "/auth/exchange" { + ( + "200 OK", + serde_json::json!({ + "user": { "id": "cloud-user", "email": "cloud@example.com" }, + "accessToken": fake_jwt("cloud-org"), + "refreshToken": "refresh-1", + }) + .to_string(), + ) + } else { + ("404 Not Found", r#"{"error":"unexpected_request"}"#.into()) + }; + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; +} + +async fn read_request(stream: &mut tokio::net::TcpStream) -> Option<(String, String)> { + let mut buffer = Vec::new(); + let mut chunk = [0u8; 1024]; + let header_end = loop { + if let Some(position) = buffer.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + break position + 4; + } + let read = stream.read(&mut chunk).await.ok()?; + if read == 0 { + return None; + } + buffer.extend_from_slice(&chunk[..read]); + }; + let headers = String::from_utf8_lossy(&buffer[..header_end]); + let mut lines = headers.lines(); + let target = lines.next()?.split_whitespace().nth(1)?.to_string(); + let content_length = lines + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0); + let mut body = buffer[header_end..].to_vec(); + while body.len() < content_length { + let read = stream.read(&mut chunk).await.ok()?; + if read == 0 { + break; + } + body.extend_from_slice(&chunk[..read]); + } + Some((target, String::from_utf8_lossy(&body).into_owned())) +} + +fn fake_jwt(org_id: &str) -> String { + let claims = serde_json::json!({ + "iat": 1_000, + "exp": 4_600, + "org_id": org_id, + }); + format!("e30.{}.sig", base64url(claims.to_string().as_bytes())) +} + +fn base64url(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut output = String::new(); + for chunk in bytes.chunks(3) { + let bytes = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let value = (u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2]); + output.push(ALPHABET[(value >> 18) as usize & 63] as char); + output.push(ALPHABET[(value >> 12) as usize & 63] as char); + if chunk.len() > 1 { + output.push(ALPHABET[(value >> 6) as usize & 63] as char); + } + if chunk.len() > 2 { + output.push(ALPHABET[value as usize & 63] as char); + } + } + output +} + +fn query_param(url: &str, key: &str) -> Option { + url.split_once('?')? + .1 + .split('&') + .filter_map(|part| part.split_once('=')) + .find(|(name, _)| *name == key) + .map(|(_, value)| value.to_string()) +} + +#[tokio::test] +async fn signing_in_does_not_activate_sync_for_the_running_local_profile() { + let edge = RecordingEdge::start().await; + let dir = tempfile::tempdir().expect("tempdir"); + let config = config(dir.path(), edge.url.clone(), Some("client_test"), None); + let auth = Engine::build_auth(&config).await; + let scope = Engine::initial_workspace_scope(&auth); + let profile = Engine::resolve_profile(&config, &auth, scope) + .expect("resolve local profile") + .expect("local profile is ready"); + let runtime = Engine::assemble_runtime(&config, auth.clone(), profile) + .await + .expect("assemble local runtime"); + + assert_eq!(scope, WorkspaceScope::Local); + assert!(runtime.core().links().is_none()); + + let sign_in_url = auth.start_headless_sign_in(); + let state = query_param(&sign_in_url, "state").expect("sign-in state"); + auth.complete_sign_in(&format!("{state}.authorization-code")) + .await + .expect("complete sign in"); + assert!( + matches!(auth.state(), AuthState::SignedIn { org_id: Some(org), .. } if org == "cloud-org") + ); + + runtime + .core() + .workspace + .create_space( + "still-local-space", + &runtime.core().device_id, + "/private/still-local", + None, + false, + ) + .expect("create space after sign in"); + runtime + .core() + .workspace + .create_chat("still-local-chat", "still-local-space", None, None) + .expect("create chat after sign in"); + runtime + .core() + .doc_host + .open("still-local-chat") + .expect("open local chat doc") + .write_user_message("message-1", "This stays local", 1) + .expect("write local message"); + runtime + .core() + .uploads + .append("still-local-upload", "cHJpdmF0ZQ==", Some(0)) + .expect("stage local upload after sign in"); + let upload = runtime + .core() + .uploads + .commit("still-local-upload", "private.png") + .expect("commit local upload after sign in"); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert_eq!(runtime.workspace_scope(), WorkspaceScope::Local); + assert!(runtime.core().links().is_none()); + assert!(Path::new(&upload).starts_with(dir.path().join("profiles/local/uploads"))); + let requests = edge.requests.lock().expect("requests lock").clone(); + assert_eq!( + requests + .iter() + .filter(|target| target.starts_with("/auth/exchange")) + .count(), + 1, + "the sign-in exchange itself is expected: {requests:?}" + ); + assert!( + requests.iter().all(|target| { + !["/registry/", "/session/", "/device/", "/attachments/"] + .iter() + .any(|prefix| target.starts_with(prefix)) + }), + "the captured local runtime attempted an online write: {requests:?}" + ); + + runtime.shutdown().await; +} From f4fb12902a6664558fb51d41a7cd1ed2c7c0fc49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 12:04:48 -0400 Subject: [PATCH 06/18] docs: document local-first usage and optional sync --- ARCHITECTURE.md | 95 +++++++++++++++++++++++++++++-------------------- README.md | 42 ++++++++++++++++------ 2 files changed, 87 insertions(+), 50 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ecd53bb5..88159c17 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ A ground-up native rewrite of [comet](../comet) — a multi-device controller fo (Claude Code / Codex) — in Rust, with a gpui UI. Fresh app; no backwards compatibility required. **Pillars (from the goal):** -- Sync is Loro CRDT docs (loro-mirror model) through Cloudflare Durable Objects. +- Optional sync uses Loro CRDT docs (loro-mirror model) through Cloudflare Durable Objects; the same docs persist locally when sync is disabled. - Durable Objects stay **TypeScript** (decision + evidence: `docs/research/durable-objects-language.md`). Everything device-side is Rust. - Feature parity with comet **except token-usage display** (poor fit for CRDTs; excluded). @@ -17,18 +17,14 @@ A ground-up native rewrite of [comet](../comet) — a multi-device controller fo ``` gpui UI ─ in-proc/localhost RPC ─ engine A ══ DeviceRoom DO relay ══ engine B ─ RPC ─ gpui UI - │ (edge Worker: auth, rooms, R2) │ - └── Loro sync ── SessionRoom DO (per chat) ──────────┘ - └── Workspace doc room (per org) ────────┘ + │ optional edge Worker: auth, rooms, R2 │ + └── optional Loro sync ─ SessionRoom DO (per chat) ──┘ + └─ Workspace registry room ────┘ ``` - **Engine = backend** (was `@comet/backend`): runs agents, owns auth, terminals, repos/worktrees, diff sync, doc hosting. Pure Rust daemon, fully functional headless. -- **UI = viewport** (was Electron): gpui app rendering engine state. Talks the same typed RPC - whether the engine is in-process or a separate daemon. Organized around **spaces** — synced - (device, folder) pairs: the sidebar lists spaces plus a global attention-sorted Active list; - the main area shows the selected space's sessions as horizontal tabs (closing a tab archives); - new sessions are minted onto the space's device via relay-forwardable RPCs. +- **UI = viewport** (was Electron): gpui app rendering engine state. Talks the same typed RPC whether the engine is in-process or a separate daemon. Organized around **spaces** — (device, folder) pairs, local or synced according to the active profile: the sidebar lists spaces plus a global attention-sorted Active list; the main area shows the selected space's sessions as horizontal tabs (closing a tab archives); new sessions are minted onto the space's device via relay-forwardable RPCs. - **Edge (TypeScript, ported from comet `apps/edge`)**: Worker + SessionRoom DO (per chat) + DeviceRoom DO (per device) + R2 attachments + WorkOS JWKS auth. Absorbs the old `apps/server` responsibilities (WorkOS code exchange/refresh, orgs) so **Postgres, the Hono server, and @@ -42,14 +38,50 @@ Single binary `comet`: port**. The embedded engine is not private: any other viewport can attach to the running app without it first being restarted as a daemon. Binding is best-effort — if the port is taken the window still opens, having lost only the ability to host peers. -- `comet headless` — engine only; prints sign-in URL on TTY (paste-code flow), serves IPC on - localhost + hosts its DeviceRoom for remote control. A VPS runs this; a laptop's UI drives it. +- `comet headless` — engine only. A clean installation immediately serves its local profile over localhost IPC; when a saved account selects the synced profile at startup and a bearer is available, it also hosts its DeviceRoom for remote control. A VPS can run this while a laptop's UI drives it. + +### Local-first workspace profiles + +Authentication and workspace selection are deliberately separate state machines: + +- `AuthState` is live credential state: `SignedOut`, `NeedsOrganization`, or `SignedIn`. It may change after login, refresh, revocation, or logout. +- `WorkspaceScope` is the immutable storage and transport boundary captured once at engine startup: `Local`, `Synced`, or explicit `Development`. + +The engine never re-resolves an open store because `AuthState` changed. This prevents a sign-in, token refresh, or revocation from silently swapping databases or attaching online transports to a runtime that started local-only. + +| Startup condition | `WorkspaceScope` | Online transports | +| --- | --- | --- | +| WorkOS enabled, no parseable saved `session.json` | `Local` | Disabled | +| Parseable saved WorkOS session | `Synced` | Enabled when a bearer is available; organization onboarding completes before opening the store when needed | +| Explicit dev bearer / WorkOS disabled | `Development` | Enabled | + +`comet login` and `comet logout` operate on `session.json` while the engine is stopped. Login selects `Synced` for the next start; logout selects `Local` for the next start. The UI may update live authentication status, but the active `WorkspaceScope` still changes only after restart. + +The resolved profile selects the session snapshots, registry snapshot, run journals, and attachment cache that may contain workspace data: + +| Scope | Store and journals | Uploads | +| --- | --- | --- | +| `Local` | `{data_dir}/profiles/local/` | `{data_dir}/profiles/local/uploads/` | +| `Synced` | `{data_dir}/orgs/{org_id}/{user_id}/` | `{data_dir}/uploads/` | +| `Development` | `{data_dir}/orgs/{org_id}/{user_id}/` | `{data_dir}/uploads/` | + +The synced and development roots intentionally preserve the historical cloud layout. Local identity lives in `{data_dir}/local-profile.json`; its UUID is stable across restarts and is not an account or development identity. + +Device identity and machine resources remain device-scoped under the common data directory: `device-id`, repository registration, managed worktrees, agent credentials/accounts, and UI settings. They are available across profiles, but they do not contain or expose another profile's transcripts or attachments. + +#### Privacy boundary and follow-ups + +This first local-first change does not upload, import, link, or delete local sessions when a user signs in. Local attachments remain jailed under the local upload root and are not readable through the synced attachment cache. Returning to local-only mode reopens the same local identity and data. + +The following product work is intentionally deferred: + +1. Explicit session selection and copy between local and synced profiles, including attachment copying, provenance, and conflict behavior. +2. Browsing both scopes simultaneously or switching the visible scope without restarting the engine. +3. A supported self-hosted backend contract covering authentication modes, room APIs, authorization, persistence, and blob storage. Current endpoint and bearer overrides remain development/deployment seams, not a promised compatibility surface. ## 2. Data model — all Loro -Two doc kinds, one room protocol (loro-protocol over WebSocket, the same protocol the TS edge -already speaks; Rust side uses the official `loro-protocol`/`loro-websocket-client` crates or a -thin hand-rolled client over `loro` 1.13.x — verify interop early, M1 exit criterion): +Two persistent doc kinds. When sync is enabled they share one loro-protocol room protocol over WebSocket; local-only profiles persist the same docs without joining rooms: 1. **Session doc** (per chat) — the transcript + durable command queue. Schema is a Rust port of `packages/session-doc` (same container names/shapes so the edge's tail materializer keeps @@ -60,25 +92,11 @@ thin hand-rolled client over `loro` 1.13.x — verify interop early, M1 exit cri run journal), tail/diff sidecars. Constants carried over (`STREAM_COMMIT_MS=120`, `DO_FLUSH_MS=5s`, compaction at 8MB, retain 30d, tail 64). -2. **Workspace doc** (per org — NEW; replaces comet's residual entity sync) — **spaces** - registry (id, deviceId, path, name?, gitDetected, checkoutId — a space is a synced - device+folder pair, the app's unit of organization; the owning device's SpacesSync stamps git - presence so branch pickers / the diff sidebar gate on a synced bool, no RPC), chats index - (id, deviceId, title, archived, cwd, branch, checkoutId, spaceId, lastSeenAt, - lastMessagePreview/At, config), devices registry (id, name, platform, lastSeenAt), session - status rows (Working indicator; staleness-checked client-side so a crashed backend never shows - eternal "Working"), checkout-diff summary pointers. `lastSeenAt` is the synced LWW seen marker - behind the "completed (unseen)" indicator. Lives in its own DO room (same SessionRoom DO - class, doc id `ws2/{orgId}` — the `2` is the spaces-overhaul destructive break), with presence - via Loro `EphemeralStore` (replaces the 15s heartbeat writes). Writer discipline: each device - writes only its own device/session/chat rows and the git stamps of spaces it owns; - creates/renames/archives/seen-marks are LWW map sets from any device. `deleteSpace` cascades: - the space row and every chat/session row in it tombstone in one commit. - - *Why a workspace doc and not N tiny docs:* the sidebar needs one subscription for the whole - list (grouping, resort animations, unseen markers); one doc = one room connection + one mirror. - Volume is tiny (index rows, no transcripts), so oplog growth is negligible and daily compaction - applies anyway. +2. **Workspace registry doc** (per profile) — the `registry1` snapshot stores spaces (id, deviceId, path, name?, gitDetected, checkoutId), the chats index (id, deviceId, title, archived, cwd, branch, checkoutId, spaceId, lastSeenAt, lastMessagePreview/At, config), devices, session-status rows, and checkout-diff summary pointers. A space is a device+folder pair in the active profile; the owning device's `SpacesSync` stamps git presence so branch pickers and the diff sidebar can gate without another RPC. Local scope keeps the registry entirely in its profile store. Synced and development scopes join `/registry/{orgId}/ws`, backed by the private per-user room `reg1/{orgId}/{userId}`; rows are never visible to every member of an organization. + + Writer discipline: each device writes its own device and session-status rows, rows for chats it hosts, and git stamps for spaces it owns. Creates, renames, archives, and seen marks are LWW sets accepted from any device. `deleteSpace` tombstones the space and every chat/session row in it in one commit. Presence uses ephemeral room frames rather than durable heartbeat writes. + + *Why one registry and not N tiny docs:* the sidebar needs one subscription for the whole list (grouping, resort animations, unseen markers). Its rows contain indexes rather than transcripts, so one local snapshot and, when enabled, one room connection remain bounded and cheap. 3. **Mirror layer** (`comet-doc` crate) — Rust equivalent of loro-mirror: typed structs for the schema, **incremental** application of `doc.subscribe` diffs into cached state (no full @@ -104,7 +122,7 @@ comet-native/ # entities, RPC envelopes (serde; ndjson framing); # `view` = the pure derivations both frontends share # (sort orders, staleness gating, grouping, boot gate) - doc/ comet-doc # session-doc + workspace-doc schemas, mirror layer, + doc/ comet-doc # session-doc + workspace-registry schemas, mirror layer, # parts fold, continuations, command ledger, sidecars sync/ comet-sync # loro room client (join/VV backfill/fragments/backoff), # ephemeral presence, DocsStore (SQLite snapshots + @@ -196,7 +214,7 @@ Direct ports of comet behaviors (spec: feature-inventory §3): `codex exec --json`; model/reasoning/option catalogs ported from `packages/harness`. - **Repos/diffs**: git2 or `git` subprocess (subprocess — matches comet, avoids libgit2 edge cases); worktrees under `~/.comet-native/worktrees`; fs watchers (`notify`) + 2min repair; diff - capture (patch + numstat + untracked, 3MiB cap, sha256) → workspace doc summary + DO diff + capture (patch + numstat + untracked, 3MiB cap, sha256) → workspace registry summary + DO diff sidecar. - **Agent accounts**: credential-slot swap (macOS Keychain via `security-framework`, files elsewhere), plan labels, usage probes, paste-code/browser-poll OAuth flows. @@ -208,8 +226,7 @@ Direct ports of comet behaviors (spec: feature-inventory §3): Port `comet/apps/edge` nearly verbatim (it is already Loro-native and smoke-tested: session room w/ hibernation + two-level compaction + daily alarm backups, device room byte relay + nudges + sidecar slots, R2 attachments, JWKS auth). Additions: -1. Workspace-doc rooms (`ws/{orgId}`) — same DO class, org-membership authz instead of - claim-on-first-join. +1. Private per-user registry rooms (`/registry/{orgId}/ws` → `reg1/{orgId}/{userId}`) with authenticated row sync and ephemeral device presence. 2. `/auth/*` routes absorbed from `apps/server` (WorkOS API key in Worker secret). 3. Drop `/seed` migration path and legacy sync anything (fresh app). Hibernation hygiene: no idle timers (flush timer only while dirty), auto-response ping/pong — @@ -220,7 +237,7 @@ per `docs/research/durable-objects-language.md`. - **Excluded**: token-usage display (profile heatmap, lifetime stats, per-message token columns, `WatchUsage`). Rate-limit meters on agent accounts are *kept* (separate concern; probed from CLIs, not CRDT-synced). -- **Changed**: Postgres entity sync/server → workspace doc + edge; Electron/React/mugen → gpui with +- **Changed**: Postgres entity sync/server → workspace registry + edge; Electron/React/mugen → gpui with ported techniques; Node harness SDKs → subprocess protocols; WebRTC → device-room relay (comet had already made this move); mobile app → out of scope for this repo. - **Kept verbatim**: session-doc schema shape + constants, command ledger rules, edge DO design, @@ -240,7 +257,7 @@ Status legend: ✅ shipped · 🟡 shipped with named gaps (see `docs/PARITY.md` - ✅ **M3 UI core** — shell (sidebar/panes/header), transcript (virtualized, markdown, streaming, stick-to-bottom), composer (send/steer/stop, question panel); local chat fully usable headed. - ✅ **M4 Multi-device** — device-room host/client virtual sockets, remote device control, workspace - doc entity sync, WorkOS auth + org gate, presence. Proven live by `scripts/e2e-smoke.sh`: + registry sync, WorkOS auth + org gate, presence. Proven live by `scripts/e2e-smoke.sh`: two headless engines against a real edge — B queues a run into the chat doc, the durable nudge wakes host A, A executes (mock harness), transcript + session status sync back to B. - 🟡 **M5 Full surface** — terminals, diff pane, repo/branch/folder pickers + worktrees, diff --git a/README.md b/README.md index a1a4d385..695bfa5d 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,51 @@ # Comet -Control your coding agents (Claude Code, Codex) from any of your devices. +Control your coding agents (Claude Code, Codex) locally by default, with optional multi-device sync. ![Comet running a Claude Code session](docs/screenshot.png) -Every device runs a small engine that keeps your sessions in sync: start an -agent on one machine, follow and drive it from another. Install the engine as -a daemon on an always-on machine (a VPS, a spare box) and your agents keep -working after you close your laptop. +Every device runs a small engine that stores sessions on that device. A new installation starts in local-only mode without an account or a network connection. -## Install the daemon (Linux) +## Install and run locally (Linux) ```bash curl -fsSL https://comet.zeron.sh/install.sh | sh -comet login # sign in (paste a code, done) -systemctl --user start comet-native +comet status ``` -No configuration needed. Day-to-day: +The installer starts the daemon immediately and keeps it running across reboots. No sign-in or sync configuration is required. + +Day-to-day: ```bash -comet status # signed in? engine running? +comet status # local/synced mode and engine status comet update # update to the latest release comet daemon start|stop|restart|status ``` -On macOS: build `comet` from source, then `comet daemon install` (launchd). +## Optional multi-device sync + +Sign in only when you want to open your account's synced workspace. Authentication changes the profile selected by the next engine start, so stop the daemon before changing it: + +```bash +comet daemon stop +comet login +comet daemon start +``` + +You can then start an agent on one synced device and follow or drive it from another. An always-on machine such as a VPS can keep those agents working after you close your laptop. + +Signing in does not upload, move, or import existing local sessions. Local sessions and their attachments remain under the local profile and reappear when you return to local-only mode: + +```bash +comet daemon stop +comet logout +comet daemon start +``` + +`comet login` and `comet logout` refuse to modify credentials while an engine owns the data directory. The desktop app follows the same next-restart profile boundary. + +On macOS: use the desktop release, or build `comet` from source and run `comet daemon install` to install the launchd service. --- From 484d983ef28dc16e152df7c060c13200f4286f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 12:20:20 -0400 Subject: [PATCH 07/18] fix(ui): block all synced viewports after sign-out --- crates/ui/src/shell.rs | 57 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index c7c96de1..6b92e222 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -458,11 +458,18 @@ fn sync_flow_after_auth( } _ => flow, }, - Some(WorkspaceScope::Synced) => match flow { - SyncFlow::SignOutConfirm - | SyncFlow::SigningOut - | SyncFlow::SignedOutRestartRequired => flow, - _ => SyncFlow::Idle, + Some(WorkspaceScope::Synced) => match auth { + // AuthStatus is shared by every viewport attached to the runtime. + // Once a synced store loses its credentials, every Shell must stop: + // letting another viewport sign in would authenticate a new account + // while the engine still serves the previous account's fixed store. + Some(AuthState::SignedOut) => SyncFlow::SignedOutRestartRequired, + _ => match flow { + SyncFlow::SignOutConfirm + | SyncFlow::SigningOut + | SyncFlow::SignedOutRestartRequired => flow, + _ => SyncFlow::Idle, + }, }, Some(WorkspaceScope::Development) => SyncFlow::Idle, None => flow, @@ -4592,6 +4599,46 @@ mod tests { ); } + #[test] + fn synced_sign_out_blocks_every_viewport_and_cannot_switch_accounts() { + let signed_in_as_another_user = AuthState::SignedIn { + user: comet_proto::UserProfile { + id: "user-2".into(), + email: "other@example.com".into(), + name: None, + }, + org_id: Some("org-2".into()), + }; + + assert_eq!( + sync_flow_after_auth( + SyncFlow::SigningOut, + Some(WorkspaceScope::Synced), + Some(&AuthState::SignedOut), + ), + SyncFlow::SignedOutRestartRequired, + "the viewport that requested sign-out is blocked by AuthStatus" + ); + assert_eq!( + sync_flow_after_auth( + SyncFlow::Idle, + Some(WorkspaceScope::Synced), + Some(&AuthState::SignedOut), + ), + SyncFlow::SignedOutRestartRequired, + "another viewport observing the same runtime is also blocked" + ); + assert_eq!( + sync_flow_after_auth( + SyncFlow::SignedOutRestartRequired, + Some(WorkspaceScope::Synced), + Some(&signed_in_as_another_user), + ), + SyncFlow::SignedOutRestartRequired, + "new credentials cannot reopen the previous account's store" + ); + } + #[test] fn titlebar_cluster_matches_comet_window_controls() { // comet window-controls.tsx: `left: fullscreen ? 12 : 88` — the From f1419428dc0836e258dd3080f7b5dd4d0df307a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 12:21:50 -0400 Subject: [PATCH 08/18] fix(engine): require a bearer for development sync --- ARCHITECTURE.md | 3 ++- crates/engine/src/lib.rs | 17 ++++++++++++----- crates/engine/tests/local_first.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 88159c17..6243f4e2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -53,7 +53,8 @@ The engine never re-resolves an open store because `AuthState` changed. This pre | --- | --- | --- | | WorkOS enabled, no parseable saved `session.json` | `Local` | Disabled | | Parseable saved WorkOS session | `Synced` | Enabled when a bearer is available; organization onboarding completes before opening the store when needed | -| Explicit dev bearer / WorkOS disabled | `Development` | Enabled | +| WorkOS disabled without a dev bearer | `Development` | Disabled | +| Explicit non-empty dev bearer | `Development` | Enabled | `comet login` and `comet logout` operate on `session.json` while the engine is stopped. Login selects `Synced` for the next start; logout selects `Local` for the next start. The UI may update live authentication status, but the active `WorkspaceScope` still changes only after restart. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cb5798e8..34fa7dfa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -79,7 +79,8 @@ pub struct EngineConfig { pub data_dir: PathBuf, /// Edge base URL. pub edge_url: String, - /// Bearer for edge room joins; `None` runs fully offline (sync disabled). + /// Explicit development bearer for edge room joins. Synced WorkOS runtimes + /// obtain their bearer from [`Auth`]; development stays offline when this is absent. pub edge_token: Option, /// Localhost IPC port for the UI. pub ipc_port: u16, @@ -480,7 +481,7 @@ impl Engine { } /// Open one already-resolved profile and enable online transports only for - /// synced and explicit development runtimes. + /// synced runtimes with live auth or development with an explicit bearer. pub async fn assemble_runtime( config: &EngineConfig, auth: Auth, @@ -488,9 +489,15 @@ impl Engine { ) -> anyhow::Result { let online = match profile.scope() { WorkspaceScope::Local => false, - WorkspaceScope::Synced | WorkspaceScope::Development => { - auth.access_token().await.is_some() - } + WorkspaceScope::Synced => auth.access_token().await.is_some(), + // Dev Auth always exposes `dev_user_id` as its synthetic access + // token, including when WorkOS was merely disabled with + // COMET_WORKOS_CLIENT_ID="". Only an explicitly configured, + // non-empty bearer opts this runtime into Edge rooms and relays. + WorkspaceScope::Development => config + .edge_token + .as_deref() + .is_some_and(|token| !token.trim().is_empty()), }; let device_id = load_or_create_device_id(profile.device_root())?; let edge = online.then(|| { diff --git a/crates/engine/tests/local_first.rs b/crates/engine/tests/local_first.rs index 4863f6ad..35ca76b3 100644 --- a/crates/engine/tests/local_first.rs +++ b/crates/engine/tests/local_first.rs @@ -138,6 +138,33 @@ async fn revoked_captured_session_stays_on_its_synced_cache() { edge_task.abort(); } +#[tokio::test] +async fn development_without_an_explicit_bearer_stays_offline() { + let dir = tempfile::tempdir().unwrap(); + let (edge_url, requests, edge_task) = rejecting_edge().await; + let config = config(dir.path(), edge_url, None, None); + let auth = Engine::build_auth(&config).await; + let scope = Engine::initial_workspace_scope(&auth); + let profile = Engine::resolve_profile(&config, &auth, scope) + .unwrap() + .expect("development profile is ready"); + + assert_eq!(scope, WorkspaceScope::Development); + assert_eq!(auth.access_token().await.as_deref(), Some("dev-user")); + let runtime = Engine::assemble_runtime(&config, auth, profile) + .await + .unwrap(); + + assert_eq!(runtime.workspace_scope(), WorkspaceScope::Development); + assert!( + runtime.core().links().is_none(), + "the synthetic dev identity must not enable Edge" + ); + assert_eq!(requests.load(Ordering::SeqCst), 0); + runtime.shutdown().await; + edge_task.abort(); +} + #[tokio::test] async fn explicit_dev_bearer_keeps_online_routing_enabled() { let dir = tempfile::tempdir().unwrap(); From c37dafcbb919396ecd5db1a2cf9091057d310194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 12:24:18 -0400 Subject: [PATCH 09/18] fix(ui): share pending sync restart across viewports --- crates/ui/src/shell.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 6b92e222..148852b0 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -453,9 +453,12 @@ fn sync_flow_after_auth( ) -> SyncFlow { match scope { Some(WorkspaceScope::Local) => match (flow, auth) { - (SyncFlow::Enabling, Some(AuthState::SignedIn { .. })) => { - SyncFlow::RestartPending { notice_open: true } - } + // AuthStatus belongs to the runtime, not to the Shell that opened + // the browser. Every attached viewport must advertise the pending + // profile switch once any of them completes sign-in. + (SyncFlow::Canceling, Some(AuthState::SignedIn { .. })) => flow, + (SyncFlow::RestartPending { .. }, Some(AuthState::SignedIn { .. })) => flow, + (_, Some(AuthState::SignedIn { .. })) => SyncFlow::RestartPending { notice_open: true }, _ => flow, }, Some(WorkspaceScope::Synced) => match auth { @@ -4590,6 +4593,24 @@ mod tests { ), SyncFlow::RestartPending { notice_open: true } ); + assert_eq!( + sync_flow_after_auth( + SyncFlow::Idle, + Some(WorkspaceScope::Local), + Some(&signed_in), + ), + SyncFlow::RestartPending { notice_open: true }, + "another viewport derives the pending restart from AuthStatus" + ); + assert_eq!( + sync_flow_after_auth( + SyncFlow::RestartPending { notice_open: false }, + Some(WorkspaceScope::Local), + Some(&signed_in), + ), + SyncFlow::RestartPending { notice_open: false }, + "shared auth updates do not reopen a postponed notice" + ); assert_eq!( account_menu_action( Some(WorkspaceScope::Local), From c7df2f85b95e80ed1810dd09da66ccb64530919a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 12:27:29 -0400 Subject: [PATCH 10/18] fix(engine): publish device identity atomically --- crates/engine/src/lib.rs | 56 ++++++++++++++++++++++++--- crates/engine/tests/local_profiles.rs | 47 +++++++++++++++++++++- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34fa7dfa..5d32dde6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -5,6 +5,7 @@ //! sessions + docs + commands + minimal IPC. Terminals, repos/diffs, uploads, auth, //! agent accounts, and the device-room host land in later milestones. +use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -804,13 +805,58 @@ fn env_or(key: &str, default: &str) -> String { /// Stable per-installation device id, persisted at `{data_dir}/device-id`. fn load_or_create_device_id(data_dir: &Path) -> Result { + std::fs::create_dir_all(data_dir)?; let path = data_dir.join("device-id"); match std::fs::read_to_string(&path) { - Ok(id) if !id.trim().is_empty() => Ok(id.trim().to_string()), - Ok(_) | Err(_) => { - let id = new_id(); - std::fs::write(&path, &id)?; - Ok(id) + Ok(id) if !id.trim().is_empty() => return Ok(id.trim().to_string()), + Ok(_) => { + return Err(EngineError::Other(format!( + "invalid device identity {}: file is empty", + path.display() + ))); } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + let id = new_id(); + let temp_path = data_dir.join(format!( + ".device-id.tmp-{}-{}", + std::process::id(), + new_id() + )); + let write_result = (|| -> Result<(), EngineError> { + let mut temp = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path)?; + temp.write_all(id.as_bytes())?; + temp.sync_all()?; + Ok(()) + })(); + if let Err(err) = write_result { + let _ = std::fs::remove_file(&temp_path); + return Err(err); + } + + // Publish only fully-written bytes and never replace another process's + // winner. A hard link is the portable create-if-absent primitive already + // used for local-profile identity; losers can immediately read the winner. + let publish_result = std::fs::hard_link(&temp_path, &path); + let _ = std::fs::remove_file(&temp_path); + match publish_result { + Ok(()) => Ok(id), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + let winner = std::fs::read_to_string(&path)?; + if winner.trim().is_empty() { + Err(EngineError::Other(format!( + "invalid device identity {}: file is empty", + path.display() + ))) + } else { + Ok(winner.trim().to_string()) + } + } + Err(err) => Err(err.into()), } } diff --git a/crates/engine/tests/local_profiles.rs b/crates/engine/tests/local_profiles.rs index 58ac3aab..f40c9002 100644 --- a/crates/engine/tests/local_profiles.rs +++ b/crates/engine/tests/local_profiles.rs @@ -1,7 +1,7 @@ //! Integrated regressions for local-first profile privacy and lifecycle boundaries. use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Barrier, Mutex}; use comet_engine::{ AuthState, Engine, EngineConfig, EngineCore, EngineProfile, HarnessId, WorkspaceScope, @@ -37,6 +37,51 @@ async fn shutdown(core: EngineCore) { drop(core); } +#[tokio::test] +async fn concurrent_engine_info_and_runtime_share_one_device_identity() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = Arc::new(config( + dir.path(), + "http://127.0.0.1:1".into(), + Some("client_test"), + None, + )); + let workers = 32; + let barrier = Arc::new(Barrier::new(workers)); + let calls = (0..workers) + .map(|_| { + let config = config.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + Engine::engine_info(&config, WorkspaceScope::Local) + .expect("resolve concurrent engine info") + .device_id + }) + }) + .collect::>(); + let announced = calls + .into_iter() + .map(|call| call.join().expect("engine-info worker")) + .collect::>(); + + assert_eq!(announced.len(), 1, "every viewport announces one identity"); + let announced = announced.into_iter().next().expect("announced id"); + assert_eq!( + std::fs::read_to_string(dir.path().join("device-id")) + .expect("persisted device id") + .trim(), + announced + ); + + let core = assemble(EngineProfile::local(dir.path()).expect("local profile")); + assert_eq!( + core.device_id, announced, + "the assembled runtime must use the identity already announced" + ); + shutdown(core).await; +} + #[tokio::test] async fn local_and_synced_profiles_remain_isolated_across_restarts() { let dir = tempfile::tempdir().expect("tempdir"); From a8246568bb0636170b0f9738a38fc9fa1ececf88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 12:49:40 -0400 Subject: [PATCH 11/18] fix(ui): stop remote daemon before runtime changes --- crates/engine/src/lib.rs | 44 +++++++++++- crates/engine/tests/local_first.rs | 47 ++++++++++++- crates/rpc/src/lib.rs | 4 ++ crates/ui/src/shell.rs | 109 +++++++++++++++++++++++++++-- crates/ui/src/state.rs | 8 +++ 5 files changed, 202 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5d32dde6..74c7edc5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,7 +9,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; +use async_trait::async_trait; pub use comet_proto::{EngineInfo, HarnessId, WorkspaceScope}; +use comet_rpc::{RpcError, RpcReply, RpcService, methods}; use comet_sync::DocsStore; @@ -368,6 +370,32 @@ pub struct EngineRuntime { _host_relay: Option, } +/// IPC-only lifecycle control owned by `comet headless`. The regular +/// [`EngineRpc`] deliberately does not expose this method, so a viewport +/// attached to another headed process cannot shut down that process's engine. +struct HeadlessRpc { + inner: Arc, + stop_tx: tokio::sync::mpsc::UnboundedSender<()>, +} + +#[async_trait] +impl RpcService for HeadlessRpc { + async fn handle(&self, method: &str, params: serde_json::Value) -> Result { + if method != methods::STOP_ENGINE { + return self.inner.handle(method, params).await; + } + + let stop_tx = self.stop_tx.clone(); + // Let the unary success frame reach the client before `Engine::run` + // aborts the IPC server and drains the runtime. + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let _ = stop_tx.send(()); + }); + RpcReply::value(&serde_json::json!({ "ok": true })) + } +} + impl EngineRuntime { pub fn core(&self) -> &EngineCore { &self.core @@ -574,9 +602,21 @@ impl Engine { // A daemon exists to serve this port, so a bind failure is fatal here — // unlike the headed app, which can still work over its in-process // transport (see `serve_ipc`). - let server = serve_ipc(config.ipc_port, runtime.core().rpc_service()).await?; + let (stop_tx, mut stop_rx) = tokio::sync::mpsc::unbounded_channel(); + let service: Arc = Arc::new(HeadlessRpc { + inner: runtime.core().rpc_service(), + stop_tx, + }); + let server = serve_ipc(config.ipc_port, service).await?; - shutdown_signal().await?; + tokio::select! { + result = shutdown_signal() => result?, + requested = stop_rx.recv() => { + if requested.is_some() { + tracing::info!("headless shutdown requested over IPC"); + } + } + } tracing::info!("shutting down"); server.abort(); runtime.shutdown().await; diff --git a/crates/engine/tests/local_first.rs b/crates/engine/tests/local_first.rs index 35ca76b3..df27a163 100644 --- a/crates/engine/tests/local_first.rs +++ b/crates/engine/tests/local_first.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use comet_engine::{AuthState, Engine, EngineConfig, EngineInfo, HarnessId, WorkspaceScope}; -use comet_rpc::{memory_client, methods}; +use comet_rpc::{connect_ws, memory_client, methods}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; fn config( @@ -189,3 +189,48 @@ async fn explicit_dev_bearer_keeps_online_routing_enabled() { assert!(dir.path().join("orgs/dev-org/dev-user").is_dir()); runtime.shutdown().await; } + +#[tokio::test] +async fn headless_stop_rpc_drains_the_daemon_and_releases_ipc() { + let dir = tempfile::tempdir().unwrap(); + let port = { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + listener.local_addr().unwrap().port() + }; + let mut engine_config = config( + dir.path(), + "http://127.0.0.1:1".into(), + Some("client_test"), + None, + ); + engine_config.ipc_port = port; + let daemon = tokio::spawn(Engine::new(engine_config).run()); + + let client = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Ok(client) = connect_ws(&format!("ws://127.0.0.1:{port}")).await { + break client; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("headless IPC did not start"); + + assert_eq!( + client + .call(methods::STOP_ENGINE, serde_json::json!({})) + .await + .unwrap(), + serde_json::json!({ "ok": true }) + ); + tokio::time::timeout(std::time::Duration::from_secs(5), daemon) + .await + .expect("headless engine did not stop") + .expect("headless task panicked") + .expect("headless shutdown failed"); + + tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .expect("headless IPC port remained occupied after shutdown"); +} diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 2fafedaa..61642002 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -61,6 +61,10 @@ pub mod methods { pub const LOCAL_DEVICE: &str = "LocalDevice"; /// This engine runtime's fixed device and workspace identity. pub const ENGINE_INFO: &str = "EngineInfo"; + /// Ask a headless IPC owner to drain its runtime and exit successfully. + /// Headed IPC owners do not implement this method: closing another app's + /// engine behind its windows would leave that process unusable. + pub const STOP_ENGINE: &str = "StopEngine"; pub const AUTH_STATUS: &str = "AuthStatus"; // AuthRpc mutations (feature-inventory §2 AuthRpc; IPC-only). pub const SIGN_IN: &str = "SignIn"; diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 148852b0..6aab3744 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -42,8 +42,8 @@ use crate::settings::{ SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, TERMINAL_DEFAULT_HEIGHT, UiSettings, platform_combo, }; use crate::state::{ - AppState, ConnectionStatus, EngineBootConfig, GatePhase, Indicator, OrgRow, format_time_ago, - org_name_valid, parse_orgs, sort_memberships, + AppState, ConnectionStatus, EngineBootConfig, EngineMode, GatePhase, Indicator, OrgRow, + format_time_ago, org_name_valid, parse_orgs, sort_memberships, }; use crate::terminal::panel::{TerminalPanel, ToggleTerminal, clamp_terminal_height}; use crate::theme::Theme; @@ -566,6 +566,8 @@ pub struct Shell { sync_flow: SyncFlow, mutate_task: Option>, auth_task: Option>, + runtime_change_task: Option>, + runtime_change_error: Option, /// Kept for the failed-gate "Retry" action. boot: EngineBootConfig, data_dir: PathBuf, @@ -747,6 +749,8 @@ impl Shell { sync_flow: SyncFlow::Idle, mutate_task: None, auth_task: None, + runtime_change_task: None, + runtime_change_error: None, boot, data_dir, settings, @@ -1469,7 +1473,40 @@ impl Shell { } fn quit_for_runtime_change(&mut self, cx: &mut Context) { - cx.quit(); + let Some(engine) = self.state.read(cx).engine().cloned() else { + self.runtime_change_error = Some("Engine not connected".into()); + cx.notify(); + return; + }; + if engine.mode() == EngineMode::InProcess { + cx.quit(); + return; + } + if self.runtime_change_task.is_some() { + return; + } + + self.runtime_change_error = None; + self.runtime_change_task = Some(cx.spawn(async move |this, cx| { + let result = engine + .client() + .call(methods::STOP_ENGINE, serde_json::json!({})) + .await; + this.update(cx, |shell, cx| { + shell.runtime_change_task = None; + match result { + Ok(_) => cx.quit(), + Err(err) => { + shell.runtime_change_error = Some(format!( + "Could not stop the remote engine: {err}. Run `comet daemon stop`, then quit and reopen Comet." + ).into()); + cx.notify(); + } + } + }) + .ok(); + })); + cx.notify(); } fn start_sign_in(&mut self, cx: &mut Context) { @@ -2744,6 +2781,18 @@ impl Shell { self.state.read(cx).auth.as_ref(), Some(AuthState::NeedsOrganization { .. }) ); + let remote_engine = self + .state + .read(cx) + .engine() + .is_some_and(|engine| matches!(engine.mode(), EngineMode::Remote { .. })); + let runtime_change_label = if self.runtime_change_task.is_some() { + "Stopping engine…" + } else if remote_engine { + "Stop daemon and quit" + } else { + "Quit Comet" + }; if self.sync_flow == SyncFlow::Enabling && needs_org { return Some(self.render_org_gate(cx)); @@ -2798,9 +2847,23 @@ impl Shell { .child( div().mt(px(6.0)).child(popover::dialog_body( &theme, - "Quit and reopen Comet to start the synced workspace. Existing local sessions stay on this device and will not be uploaded.", + if remote_engine { + "Comet is using a background daemon. Stop it and quit Comet, then reopen to start the synced workspace. Existing local sessions stay on this device and will not be uploaded." + } else { + "Quit and reopen Comet to start the synced workspace. Existing local sessions stay on this device and will not be uploaded." + }, )), ) + .when_some(self.runtime_change_error.clone(), |card, error| { + card.child( + div() + .mt(px(10.0)) + .text_size(px(12.0)) + .line_height(px(17.0)) + .text_color(theme.danger) + .child(error), + ) + }) .child( div() .mt(px(16.0)) @@ -2816,8 +2879,11 @@ impl Shell { })), ) .child( - popover::btn_primary(&theme, "Quit Comet") + popover::btn_primary(&theme, runtime_change_label) .id("sync-restart-quit") + .when(self.runtime_change_task.is_some(), |button| { + button.opacity(0.6) + }) .on_click(cx.listener(|this, _, _, cx| { this.quit_for_runtime_change(cx) })), @@ -3555,6 +3621,18 @@ impl Shell { fn render_signed_out_restart(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); + let remote_engine = self + .state + .read(cx) + .engine() + .is_some_and(|engine| matches!(engine.mode(), EngineMode::Remote { .. })); + let runtime_change_label = if self.runtime_change_task.is_some() { + "Stopping engine…" + } else if remote_engine { + "Stop daemon and quit" + } else { + "Quit Comet" + }; let card = div() .w(px(380.0)) .px(px(32.0)) @@ -3590,12 +3668,29 @@ impl Shell { .line_height(px(19.0)) .text_color(theme.text_muted) .child(SharedString::from( - "Quit and reopen Comet before continuing. This prevents the previous synced workspace from running without its credentials.", + if remote_engine { + "Comet is using a background daemon. Stop it and quit Comet before reopening so the previous synced workspace cannot keep running without its credentials." + } else { + "Quit and reopen Comet before continuing. This prevents the previous synced workspace from running without its credentials." + }, )), ) + .when_some(self.runtime_change_error.clone(), |card, error| { + card.child( + div() + .mb(px(16.0)) + .text_size(px(12.0)) + .line_height(px(17.0)) + .text_color(theme.danger) + .child(error), + ) + }) .child( - popover::btn_primary(&theme, "Quit Comet") + popover::btn_primary(&theme, runtime_change_label) .id("signed-out-quit") + .when(self.runtime_change_task.is_some(), |button| { + button.opacity(0.6) + }) .on_click(cx.listener(|this, _, _, cx| this.quit_for_runtime_change(cx))), ); diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index fcce7fd9..c284cfbc 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -1427,6 +1427,14 @@ mod tests { .await .unwrap(); assert!(harnesses.as_array().is_some_and(|h| !h.is_empty())); + assert!(matches!( + handle + .client() + .call(methods::STOP_ENGINE, serde_json::json!({})) + .await, + Err(RpcError::Failed(message)) + if message == format!("unknown method: {}", methods::STOP_ENGINE) + )); } fn chat(id: &str, created_min: i64, last_msg_min: Option) -> Chat { From cefcbb681af9c57ca5664aea5fa146600c0cb358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 12:52:27 -0400 Subject: [PATCH 12/18] fix(auth): invalidate canceled OAuth exchanges --- crates/engine/src/auth.rs | 81 +++++++++++++++++++++++++++---------- crates/engine/tests/auth.rs | 55 ++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/auth.rs b/crates/engine/src/auth.rs index e78bd4e7..cb54edbd 100644 --- a/crates/engine/src/auth.rs +++ b/crates/engine/src/auth.rs @@ -215,8 +215,9 @@ struct AuthInner { state_tx: watch::Sender, stored: Mutex>, access: Mutex>, - /// Pending sign-in `state` values (CSRF), stamped so abandoned attempts expire. - pending: Mutex>, + /// Pending OAuth states plus the cancellation generation that fences code + /// exchanges already in flight when sign-out occurs. + sign_in: Mutex, /// Single-flight refresh: WorkOS refresh tokens are single-use (rotated per /// exchange); two concurrent refreshes would race and could revoke the session. refresh_gate: tokio::sync::Mutex<()>, @@ -224,6 +225,12 @@ struct AuthInner { loopback: tokio::sync::Mutex>, } +#[derive(Default)] +struct SignInLifecycle { + generation: u64, + pending: HashMap, +} + /// The auth service — cheap to clone by `Arc`. #[derive(Clone)] pub struct Auth { @@ -272,7 +279,7 @@ impl Auth { state_tx, stored: Mutex::new(stored), access: Mutex::new(None), - pending: Mutex::new(HashMap::new()), + sign_in: Mutex::new(SignInLifecycle::default()), refresh_gate: tokio::sync::Mutex::new(()), loopback: tokio::sync::Mutex::new(None), }), @@ -442,18 +449,26 @@ impl Auth { } let trimmed = pasted.trim(); let (state, code) = trimmed.split_once('.').unwrap_or(("", "")); - if state.is_empty() || code.is_empty() || !self.take_pending(state) { + if state.is_empty() || code.is_empty() { return Err(EngineError::Other( "invalid or expired sign-in code — start sign-in again and paste the full code" .into(), )); } + let Some(generation) = self.take_pending(state) else { + return Err(EngineError::Other( + "invalid or expired sign-in code — start sign-in again and paste the full code" + .into(), + )); + }; let result = self.exchange_code(code).await?; - self.finish_sign_in(result); - Ok(()) + self.finish_sign_in(result, generation) } pub fn sign_out(&self) { + let mut sign_in = lock(&self.inner.sign_in); + sign_in.generation = sign_in.generation.wrapping_add(1); + sign_in.pending.clear(); *lock(&self.inner.stored) = None; *lock(&self.inner.access) = None; self.persist::<&StoredSession>(None); @@ -522,10 +537,12 @@ impl Auth { fn begin_sign_in(&self, redirect_uri: &str) -> String { let state = uuid::Uuid::new_v4().to_string(); { - let mut pending = lock(&self.inner.pending); + let mut sign_in = lock(&self.inner.sign_in); let cutoff = Instant::now(); - pending.retain(|_, at| cutoff.duration_since(*at) < SIGN_IN_TTL); - pending.insert(state.clone(), cutoff); + sign_in + .pending + .retain(|_, at| cutoff.duration_since(*at) < SIGN_IN_TTL); + sign_in.pending.insert(state.clone(), cutoff); } let client_id = self.inner.workos.clone().unwrap_or_default(); format!( @@ -537,12 +554,16 @@ impl Auth { ) } - /// Consume a pending sign-in state; false when unknown/expired (CSRF check). - fn take_pending(&self, state: &str) -> bool { - let mut pending = lock(&self.inner.pending); + /// Consume a pending sign-in state and capture its cancellation generation. + /// `None` means unknown/expired (CSRF check). + fn take_pending(&self, state: &str) -> Option { + let mut sign_in = lock(&self.inner.sign_in); let now = Instant::now(); - pending.retain(|_, at| now.duration_since(*at) < SIGN_IN_TTL); - pending.remove(state).is_some() + sign_in + .pending + .retain(|_, at| now.duration_since(*at) < SIGN_IN_TTL); + sign_in.pending.remove(state)?; + Some(sign_in.generation) } async fn exchange_code(&self, code: &str) -> Result { @@ -602,7 +623,16 @@ impl Auth { }) } - fn finish_sign_in(&self, result: SignInResult) { + fn finish_sign_in(&self, result: SignInResult, generation: u64) -> Result<(), EngineError> { + // Serialize the final commit with sign-out. A callback can consume its + // OAuth state and spend time exchanging the code; if cancellation wins + // during that await, its old generation must never restore credentials. + let sign_in = lock(&self.inner.sign_in); + if sign_in.generation != generation { + return Err(EngineError::Other( + "sign-in was canceled — start again from Comet".into(), + )); + } let org_id = jwt_claims(&result.access_token).and_then(|c| c.org_id); *lock(&self.inner.access) = Some(AccessEntry::fresh(result.access_token)); let session = StoredSession { @@ -617,6 +647,7 @@ impl Auth { self.inner .state_tx .send_replace(state_for(result.user, org_id)); + Ok(()) } /// Refresh the session (single-flight). `organization_id` migrates the WorkOS @@ -878,15 +909,23 @@ async fn handle_loopback_conn( let code = params.get("code"); let state = params.get("state"); match (code, state) { - (Some(code), Some(state)) if auth.take_pending(state) => { + (Some(code), Some(state)) if let Some(generation) = auth.take_pending(state) => { match auth.exchange_code(code).await { - Ok(result) => { - auth.finish_sign_in(result); - ( + Ok(result) => match auth.finish_sign_in(result, generation) { + Ok(()) => ( "200 OK", page("Signed in. You can close this tab and return to Comet."), - ) - } + ), + Err(err) => { + tracing::info!(error = %err, "auth: discarded canceled callback exchange"); + ( + "409 Conflict", + page( + "This sign-in was canceled. Start again from Comet if you still want to enable sync.", + ), + ) + } + }, Err(err) => { tracing::warn!(error = %err, "auth: loopback code exchange failed"); ( diff --git a/crates/engine/tests/auth.rs b/crates/engine/tests/auth.rs index cb549223..3f734e77 100644 --- a/crates/engine/tests/auth.rs +++ b/crates/engine/tests/auth.rs @@ -2,7 +2,7 @@ //! loopback callback, refresh rotation + revocation, org onboarding) against a stub //! edge HTTP server on a plain tokio TcpListener. -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -53,6 +53,9 @@ fn fake_jwt(ttl_secs: i64, org_id: Option<&str>) -> String { #[derive(Default)] struct StubState { exchanges: AtomicUsize, + block_exchange: AtomicBool, + exchange_started: tokio::sync::Notify, + release_exchange: tokio::sync::Notify, refreshes: AtomicUsize, /// Refresh tokens seen by /auth/refresh, in order. refresh_tokens: Mutex>, @@ -166,6 +169,10 @@ async fn handle(mut stream: tokio::net::TcpStream, state: Arc) { return; } let n = state.exchanges.fetch_add(1, Ordering::SeqCst) + 1; + if state.block_exchange.load(Ordering::SeqCst) { + state.exchange_started.notify_one(); + state.release_exchange.notified().await; + } let org = state.exchange_org.lock().expect("lock").clone(); let token = fake_jwt(ttl, (!org.is_empty()).then_some(org.as_str())); let response = serde_json::json!({ @@ -455,6 +462,52 @@ async fn loopback_callback_completes_headed_sign_in() { ); } +#[tokio::test] +async fn sign_out_invalidates_pending_and_in_flight_oauth_callbacks() { + let edge = StubEdge::start().await; + *edge.state.exchange_org.lock().expect("lock") = "org_1".into(); + let dir = tempfile::tempdir().expect("tempdir"); + let auth = Auth::new(workos_config(&edge.url(), dir.path())); + + let first_url = auth.start_sign_in().await.expect("authorize url"); + let callback = query_param(&first_url, "redirect_uri") + .expect("redirect") + .replace("%3A", ":") + .replace("%2F", "/"); + let first_state = query_param(&first_url, "state").expect("state"); + auth.sign_out(); + let canceled_pending = reqwest::get(format!("{callback}?code=old&state={first_state}")) + .await + .expect("pending callback response"); + assert_eq!(canceled_pending.status().as_u16(), 400); + assert_eq!(edge.state.exchanges.load(Ordering::SeqCst), 0); + + edge.state.block_exchange.store(true, Ordering::SeqCst); + let second_url = auth.start_sign_in().await.expect("second authorize url"); + let second_state = query_param(&second_url, "state").expect("second state"); + let callback_request = tokio::spawn(reqwest::get(format!( + "{callback}?code=in-flight&state={second_state}" + ))); + tokio::time::timeout( + Duration::from_secs(5), + edge.state.exchange_started.notified(), + ) + .await + .expect("exchange did not start"); + + auth.sign_out(); + edge.state.release_exchange.notify_one(); + let canceled_exchange = callback_request + .await + .expect("callback task") + .expect("in-flight callback response"); + + assert_eq!(canceled_exchange.status().as_u16(), 409); + assert_eq!(auth.state(), AuthState::SignedOut); + assert!(!dir.path().join("session.json").exists()); + assert_eq!(edge.state.exchanges.load(Ordering::SeqCst), 1); +} + #[tokio::test] async fn detect_probes_edge_dev_mode() { // A stub that reports auth:"dev" forces dev mode even with a client id configured. From 4b985d877b78c871683bc37ebf8f19b74769ff27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 12:54:59 -0400 Subject: [PATCH 13/18] fix(engine): recover truncated device identity --- crates/engine/src/lib.rs | 110 +++++++++++++++++++++++--- crates/engine/tests/local_profiles.rs | 64 +++++++++++---- 2 files changed, 147 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 74c7edc5..1620843a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -846,18 +846,20 @@ fn env_or(key: &str, default: &str) -> String { /// Stable per-installation device id, persisted at `{data_dir}/device-id`. fn load_or_create_device_id(data_dir: &Path) -> Result { std::fs::create_dir_all(data_dir)?; + // EngineInfo is resolved before the lifetime InstanceLock is acquired, so + // identity creation and legacy repair need their own short critical section. + // The OS releases this lock after a crash; unlike a create_new lockfile it + // cannot strand an installation permanently. + let _identity_lock = DeviceIdentityLock::acquire(data_dir)?; let path = data_dir.join("device-id"); - match std::fs::read_to_string(&path) { + let recovering_empty = match std::fs::read_to_string(&path) { Ok(id) if !id.trim().is_empty() => return Ok(id.trim().to_string()), - Ok(_) => { - return Err(EngineError::Other(format!( - "invalid device identity {}: file is empty", - path.display() - ))); - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + // Older releases used truncate+write. A crash between those operations + // left a zero-byte file that is safe to replace with a fresh identity. + Ok(_) => true, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => false, Err(err) => return Err(err.into()), - } + }; let id = new_id(); let temp_path = data_dir.join(format!( @@ -879,10 +881,24 @@ fn load_or_create_device_id(data_dir: &Path) -> Result { return Err(err); } - // Publish only fully-written bytes and never replace another process's - // winner. A hard link is the portable create-if-absent primitive already - // used for local-profile identity; losers can immediately read the winner. - let publish_result = std::fs::hard_link(&temp_path, &path); + // Fresh installs use create-if-absent. Legacy empty files need an atomic + // same-directory replacement on Unix; the Windows fallback runs under the + // identity lock and remains recoverable if interrupted. + let publish_result = if recovering_empty { + match std::fs::read_to_string(&path) { + Ok(id) if !id.trim().is_empty() => { + let _ = std::fs::remove_file(&temp_path); + return Ok(id.trim().to_string()); + } + Ok(_) => replace_empty_device_id(&temp_path, &path), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + std::fs::hard_link(&temp_path, &path) + } + Err(err) => Err(err), + } + } else { + std::fs::hard_link(&temp_path, &path) + }; let _ = std::fs::remove_file(&temp_path); match publish_result { Ok(()) => Ok(id), @@ -900,3 +916,71 @@ fn load_or_create_device_id(data_dir: &Path) -> Result { Err(err) => Err(err.into()), } } + +struct DeviceIdentityLock { + _file: std::fs::File, +} + +impl DeviceIdentityLock { + fn acquire(data_dir: &Path) -> Result { + let path = data_dir.join("device-id.lock"); + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.share_mode(0); + let mut retries = 200; + let file = loop { + match options.open(&path) { + Ok(file) => break file, + Err(err) + if err.kind() == std::io::ErrorKind::PermissionDenied && retries > 0 => + { + retries -= 1; + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Err(err) => return Err(err.into()), + } + }; + return Ok(Self { _file: file }); + } + + #[cfg(not(windows))] + let file = options.open(&path)?; + + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + loop { + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0 { + break; + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::EINTR) { + return Err(err.into()); + } + } + } + + #[cfg(not(windows))] + Ok(Self { _file: file }) + } +} + +fn replace_empty_device_id(temp_path: &Path, path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + std::fs::rename(temp_path, path) + } + #[cfg(not(unix))] + { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + std::fs::hard_link(temp_path, path) + } +} diff --git a/crates/engine/tests/local_profiles.rs b/crates/engine/tests/local_profiles.rs index f40c9002..ab124110 100644 --- a/crates/engine/tests/local_profiles.rs +++ b/crates/engine/tests/local_profiles.rs @@ -37,18 +37,12 @@ async fn shutdown(core: EngineCore) { drop(core); } -#[tokio::test] -async fn concurrent_engine_info_and_runtime_share_one_device_identity() { - let dir = tempfile::tempdir().expect("tempdir"); - let config = Arc::new(config( - dir.path(), - "http://127.0.0.1:1".into(), - Some("client_test"), - None, - )); - let workers = 32; +fn concurrent_engine_info( + config: Arc, + workers: usize, +) -> std::collections::HashSet { let barrier = Arc::new(Barrier::new(workers)); - let calls = (0..workers) + (0..workers) .map(|_| { let config = config.clone(); let barrier = barrier.clone(); @@ -59,11 +53,22 @@ async fn concurrent_engine_info_and_runtime_share_one_device_identity() { .device_id }) }) - .collect::>(); - let announced = calls + .collect::>() .into_iter() .map(|call| call.join().expect("engine-info worker")) - .collect::>(); + .collect() +} + +#[tokio::test] +async fn concurrent_engine_info_and_runtime_share_one_device_identity() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = Arc::new(config( + dir.path(), + "http://127.0.0.1:1".into(), + Some("client_test"), + None, + )); + let announced = concurrent_engine_info(config, 32); assert_eq!(announced.len(), 1, "every viewport announces one identity"); let announced = announced.into_iter().next().expect("announced id"); @@ -82,6 +87,37 @@ async fn concurrent_engine_info_and_runtime_share_one_device_identity() { shutdown(core).await; } +#[tokio::test] +async fn empty_legacy_device_identity_is_repaired_once_for_all_boots() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("device-id"), b"").expect("seed truncated identity"); + let config = Arc::new(config( + dir.path(), + "http://127.0.0.1:1".into(), + Some("client_test"), + None, + )); + + let announced = concurrent_engine_info(config, 32); + assert_eq!( + announced.len(), + 1, + "legacy repair must publish one identity" + ); + let announced = announced.into_iter().next().expect("repaired id"); + assert!(!announced.trim().is_empty()); + assert_eq!( + std::fs::read_to_string(dir.path().join("device-id")) + .expect("repaired device id") + .trim(), + announced + ); + + let core = assemble(EngineProfile::local(dir.path()).expect("local profile")); + assert_eq!(core.device_id, announced); + shutdown(core).await; +} + #[tokio::test] async fn local_and_synced_profiles_remain_isolated_across_restarts() { let dir = tempfile::tempdir().expect("tempdir"); From 5dee4fc3d00e9ce390d52aab247d5731bba24d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 13:50:17 -0400 Subject: [PATCH 14/18] fix(ui): wait for remote engine shutdown --- crates/ui/src/shell.rs | 103 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 6aab3744..4a204a28 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -21,6 +21,7 @@ use gpui::{ Task, Window, WindowControlArea, actions, div, prelude::*, px, }; +use comet_engine::InstanceLock; use comet_proto::{AuthState, WorkspaceScope}; use comet_rpc::methods; use gpui_tokio::Tokio; @@ -428,6 +429,39 @@ enum AccountMenuAction { SignOut, } +const RUNTIME_CHANGE_TIMEOUT: Duration = Duration::from_secs(10); +const RUNTIME_CHANGE_POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// Wait until a stopped daemon can no longer win the next bootstrap probe and +/// has released the data directory for the replacement runtime. +async fn wait_for_remote_engine_shutdown( + ipc_port: u16, + data_dir: &std::path::Path, + timeout: Duration, +) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let port_closed = !matches!( + tokio::time::timeout( + Duration::from_millis(200), + tokio::net::TcpStream::connect(("127.0.0.1", ipc_port)), + ) + .await, + Ok(Ok(_)) + ); + if port_closed && InstanceLock::holder(data_dir).is_none() { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "the daemon did not finish stopping within {} seconds", + timeout.as_secs() + )); + } + tokio::time::sleep(RUNTIME_CHANGE_POLL_INTERVAL).await; + } +} + fn account_menu_action(scope: Option, flow: SyncFlow) -> Option { match scope { Some(WorkspaceScope::Local) => match flow { @@ -1487,11 +1521,21 @@ impl Shell { } self.runtime_change_error = None; - self.runtime_change_task = Some(cx.spawn(async move |this, cx| { - let result = engine + let ipc_port = self.boot.ipc_port; + let data_dir = self.data_dir.clone(); + let shutdown = Tokio::spawn(cx, async move { + engine .client() .call(methods::STOP_ENGINE, serde_json::json!({})) - .await; + .await + .map_err(|err| err.to_string())?; + wait_for_remote_engine_shutdown(ipc_port, &data_dir, RUNTIME_CHANGE_TIMEOUT).await + }); + self.runtime_change_task = Some(cx.spawn(async move |this, cx| { + let result = match shutdown.await { + Ok(result) => result, + Err(err) => Err(err.to_string()), + }; this.update(cx, |shell, cx| { shell.runtime_change_task = None; match result { @@ -4653,6 +4697,59 @@ impl Render for Shell { mod tests { use super::*; + #[tokio::test] + async fn remote_shutdown_waits_for_ipc_release() { + let dir = tempfile::tempdir().unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let release = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + drop(listener); + }); + + wait_for_remote_engine_shutdown(port, dir.path(), Duration::from_secs(2)) + .await + .unwrap(); + release.await.unwrap(); + } + + #[cfg(unix)] + #[tokio::test] + async fn remote_shutdown_waits_for_engine_lock_release() { + let dir = tempfile::tempdir().unwrap(); + let lock = InstanceLock::acquire(dir.path()).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let lock_released = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let released_by_task = lock_released.clone(); + let release = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + drop(lock); + released_by_task.store(true, std::sync::atomic::Ordering::SeqCst); + }); + + wait_for_remote_engine_shutdown(port, dir.path(), Duration::from_secs(2)) + .await + .unwrap(); + assert!(lock_released.load(std::sync::atomic::Ordering::SeqCst)); + release.await.unwrap(); + } + + #[tokio::test] + async fn remote_shutdown_times_out_while_ipc_remains_open() { + let dir = tempfile::tempdir().unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let error = wait_for_remote_engine_shutdown(port, dir.path(), Duration::from_millis(100)) + .await + .unwrap_err(); + + assert!(error.contains("did not finish stopping")); + drop(listener); + } + #[test] fn account_actions_follow_the_attached_workspace_scope() { assert_eq!( From d2d6bdb0e586034be2b915792f218923ab98c7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 13:52:29 -0400 Subject: [PATCH 15/18] fix(auth): avoid unsupported match guard --- crates/engine/src/auth.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/auth.rs b/crates/engine/src/auth.rs index cb54edbd..cf9110dd 100644 --- a/crates/engine/src/auth.rs +++ b/crates/engine/src/auth.rs @@ -908,9 +908,15 @@ async fn handle_loopback_conn( .collect(); let code = params.get("code"); let state = params.get("state"); + let invalid_callback = || { + ( + "400 Bad Request", + page("Invalid or expired sign-in link. Start again from Comet."), + ) + }; match (code, state) { - (Some(code), Some(state)) if let Some(generation) = auth.take_pending(state) => { - match auth.exchange_code(code).await { + (Some(code), Some(state)) => match auth.take_pending(state) { + Some(generation) => match auth.exchange_code(code).await { Ok(result) => match auth.finish_sign_in(result, generation) { Ok(()) => ( "200 OK", @@ -933,12 +939,10 @@ async fn handle_loopback_conn( page("Sign-in failed during token exchange — check the Comet logs."), ) } - } - } - _ => ( - "400 Bad Request", - page("Invalid or expired sign-in link. Start again from Comet."), - ), + }, + None => invalid_callback(), + }, + _ => invalid_callback(), } }; let response = format!( From 15c006b0c23e6d63aacdd3ce32e30244ce428185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 13:53:31 -0400 Subject: [PATCH 16/18] fix(ui): clear stale sync restart after revocation --- crates/ui/src/shell.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 4a204a28..b6c7f308 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -490,6 +490,7 @@ fn sync_flow_after_auth( // AuthStatus belongs to the runtime, not to the Shell that opened // the browser. Every attached viewport must advertise the pending // profile switch once any of them completes sign-in. + (SyncFlow::RestartPending { .. }, Some(AuthState::SignedOut)) => SyncFlow::Idle, (SyncFlow::Canceling, Some(AuthState::SignedIn { .. })) => flow, (SyncFlow::RestartPending { .. }, Some(AuthState::SignedIn { .. })) => flow, (_, Some(AuthState::SignedIn { .. })) => SyncFlow::RestartPending { notice_open: true }, @@ -4810,6 +4811,17 @@ mod tests { ), Some(AccountMenuAction::RestartPending) ); + for notice_open in [true, false] { + assert_eq!( + sync_flow_after_auth( + SyncFlow::RestartPending { notice_open }, + Some(WorkspaceScope::Local), + Some(&AuthState::SignedOut), + ), + SyncFlow::Idle, + "revoked credentials cancel the pending synced restart" + ); + } } #[test] From 0c4f45268baf8783b1ad6a7b4a5f83de4d7bc3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 14:02:53 -0400 Subject: [PATCH 17/18] fix(auth): back off offline refresh failures --- crates/engine/src/auth.rs | 8 +++++--- crates/engine/tests/auth.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/auth.rs b/crates/engine/src/auth.rs index cf9110dd..4fd66a86 100644 --- a/crates/engine/src/auth.rs +++ b/crates/engine/src/auth.rs @@ -692,9 +692,11 @@ impl Auth { let res = match res { Ok(res) => res, Err(err) => { - // Network failure is transient: keep the session, caller retries later. - tracing::warn!(error = %err, "auth: refresh could not reach the edge"); - return Ok(None); + // Network failure is transient: keep the session, but surface the + // error so the background loop applies its retry delay. + return Err(EngineError::Other(format!( + "could not reach the edge during refresh: {err}" + ))); } }; let status = res.status().as_u16(); diff --git a/crates/engine/tests/auth.rs b/crates/engine/tests/auth.rs index 3f734e77..f790e7b3 100644 --- a/crates/engine/tests/auth.rs +++ b/crates/engine/tests/auth.rs @@ -57,6 +57,7 @@ struct StubState { exchange_started: tokio::sync::Notify, release_exchange: tokio::sync::Notify, refreshes: AtomicUsize, + drop_refresh: AtomicBool, /// Refresh tokens seen by /auth/refresh, in order. refresh_tokens: Mutex>, /// TTL (seconds) for minted access tokens. @@ -194,6 +195,10 @@ async fn handle(mut stream: tokio::net::TcpStream, state: Arc) { .lock() .expect("lock") .push(refresh_token.to_string()); + if state.drop_refresh.load(Ordering::SeqCst) { + state.refreshes.fetch_add(1, Ordering::SeqCst); + return; + } if refresh_token == "dead" { respond(&mut stream, "401 Unauthorized", r#"{"error":"revoked"}"#).await; return; @@ -425,6 +430,32 @@ async fn revoked_refresh_token_signs_out() { assert!(!dir.path().join("session.json").exists()); } +#[tokio::test] +async fn offline_refresh_loop_backs_off_without_revoking_session() { + let edge = StubEdge::start().await; + edge.state.drop_refresh.store(true, Ordering::SeqCst); + let dir = tempfile::tempdir().expect("tempdir"); + let session_file = dir.path().join("session.json"); + std::fs::write( + &session_file, + r#"{"refreshToken":"offline","user":{"id":"user_1","email":"w@example.com"},"orgId":"org_1"}"#, + ) + .expect("seed session"); + let auth = Auth::new(workos_config(&edge.url(), dir.path())); + + let refresh_loop = auth.spawn_refresh_loop(); + tokio::time::sleep(Duration::from_millis(200)).await; + + assert_eq!( + edge.state.refreshes.load(Ordering::SeqCst), + 1, + "a transport failure must enter the background retry delay" + ); + assert!(auth.state().is_signed_in()); + assert!(session_file.exists(), "offline must not revoke the session"); + refresh_loop.abort(); +} + #[tokio::test] async fn loopback_callback_completes_headed_sign_in() { let edge = StubEdge::start().await; From 296259b90b83966f9af0904bd281d0dbc69086b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 8 Aug 2026 14:07:01 -0400 Subject: [PATCH 18/18] fix(e2e): support macOS and transcript deltas --- Cargo.lock | 1 + crates/rpc/Cargo.toml | 3 ++ crates/rpc/examples/e2e_driver.rs | 56 +++++++++++++++++++++++++++---- scripts/e2e-smoke.sh | 8 +++-- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b84e9bd..6e21c319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1246,6 +1246,7 @@ version = "0.1.23" dependencies = [ "anyhow", "async-trait", + "comet-doc", "comet-proto", "comet-sync", "futures", diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 7ab9c666..9f214011 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -18,3 +18,6 @@ thiserror.workspace = true async-trait.workspace = true tracing.workspace = true uuid.workspace = true + +[dev-dependencies] +comet-doc.workspace = true diff --git a/crates/rpc/examples/e2e_driver.rs b/crates/rpc/examples/e2e_driver.rs index 33ce1d0b..74963458 100644 --- a/crates/rpc/examples/e2e_driver.rs +++ b/crates/rpc/examples/e2e_driver.rs @@ -49,7 +49,7 @@ async fn wait_stream( method: &str, params: serde_json::Value, what: &str, - predicate: impl Fn(&serde_json::Value) -> Option, + mut predicate: impl FnMut(&serde_json::Value) -> Option, ) -> T { let mut rx = match client.subscribe(method, params).await { Ok(rx) => rx, @@ -79,6 +79,15 @@ async fn wait_stream( } } +fn apply_transcript_item( + current: &mut Vec, + item: &serde_json::Value, +) -> Result<(), String> { + let frame: comet_doc::TranscriptFrame = + serde_json::from_value(item.clone()).map_err(|err| err.to_string())?; + comet_doc::apply_transcript_frame(current, frame).map_err(|err| err.to_string()) +} + #[tokio::main] async fn main() { let mut args = std::env::args().skip(1); @@ -210,18 +219,22 @@ async fn main() { pass("run command queued on B via the doc command queue"); // 5a. Assistant entry executed by A arrives back on B, complete, with the mock text. + let mut transcript = Vec::new(); let (by_device, text) = wait_stream( &b, methods::WATCH_DOC_MESSAGES, serde_json::json!({ "chatId": chat_id }), "assistant transcript on B", |item| { - let entry = item.as_array()?.iter().find(|entry| { - entry.get("role").and_then(|v| v.as_str()) == Some("assistant") - && entry.get("status").and_then(|v| v.as_str()) == Some("complete") + apply_transcript_item(&mut transcript, item) + .unwrap_or_else(|err| fail(&format!("assistant transcript on B: {err}"))); + let entry = transcript.iter().find(|entry| { + entry.role == comet_doc::MessageRole::Assistant + && entry.status == Some(comet_doc::MessageStatus::Complete) })?; - let device = entry.get("deviceId")?.as_str()?.to_string(); - let text = entry.to_string(); + let device = entry.device_id.clone(); + let text = serde_json::to_string(entry) + .unwrap_or_else(|err| fail(&format!("serialize assistant transcript: {err}"))); Some((device, text)) }, ) @@ -258,3 +271,34 @@ async fn main() { println!("PASS: two-device e2e smoke complete"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transcript_reset_frame_is_materialized() { + let mut transcript = Vec::new(); + apply_transcript_item( + &mut transcript, + &serde_json::json!({ + "reset": [{ + "id": "assistant-1", + "role": "assistant", + "parts": [{ "kind": "text", "id": "t0", "text": MOCK_TEXT }], + "createdAt": 1, + "deviceId": "device-a", + "status": "complete" + }] + }), + ) + .expect("reset frame"); + + assert_eq!(transcript.len(), 1); + assert_eq!(transcript[0].device_id, "device-a"); + assert_eq!( + transcript[0].status, + Some(comet_doc::MessageStatus::Complete) + ); + } +} diff --git a/scripts/e2e-smoke.sh b/scripts/e2e-smoke.sh index f72480f3..6a5724d6 100755 --- a/scripts/e2e-smoke.sh +++ b/scripts/e2e-smoke.sh @@ -36,7 +36,7 @@ cleanup() { kill "$pid" 2>/dev/null || true fi done - # The edge runs in its own session (setsid) — kill the whole wrangler group + # The edge runs in its own process group — kill the whole wrangler group # (npx → wrangler → workerd children). [[ -n "$EDGE_PID" ]] && kill -- -"$EDGE_PID" 2>/dev/null || true sleep 1 @@ -76,9 +76,13 @@ if curl -sf -m 3 "$EDGE_URL/health" | grep -q '"auth":"dev"'; then echo "edge: reusing healthy dev-mode worker on :$EDGE_PORT" else echo "edge: starting wrangler dev on :$EDGE_PORT" - setsid bash -c "cd '$ROOT/edge' && exec npx wrangler dev --port '$EDGE_PORT' --var AUTH_MODE:dev" \ + # Monitor mode gives the background job its own process group on both macOS + # and Linux, without depending on the Linux-only `setsid` utility. + set -m + bash -c "cd '$ROOT/edge' && exec npx wrangler dev --port '$EDGE_PORT' --var AUTH_MODE:dev" \ >"$LOG_DIR/edge.log" 2>&1 & EDGE_PID=$! + set +m wait_for "edge /health" 90 curl -sf -m 3 "$EDGE_URL/health" fi