diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6b580..5d56609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Made project changes immediate and responsive: first-time constellation + layouts now show cancellable elapsed-time progress off the UI thread, while + completed layouts are cached for instant return visits and failures restore + the last successfully charted project. +- Reworked the star map around viewer-aware work priorities: current and owned + work now remains prominent when zoomed out, team and closure states stay + distinct, account changes cannot reuse stale ownership, and completed paths + remain quiet except for subtle directional motion into immediately actionable + nodes. - Kept ready subissue labels visible and clear of their emphasis rings while the star-map camera eases. - Declared npm 12.0.2 as the web workspace's development package manager and diff --git a/crates/app/src/acceptance.rs b/crates/app/src/acceptance.rs index e6aefdd..5113b1f 100644 --- a/crates/app/src/acceptance.rs +++ b/crates/app/src/acceptance.rs @@ -2,7 +2,7 @@ use std::{path::PathBuf, sync::Arc}; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::{ credentials::{CredentialStore, CredentialStoreError}, device_flow::{AccessToken, DeviceFlowClient, DeviceFlowController, DeviceFlowStatus}, @@ -26,7 +26,7 @@ struct SignedOut; #[async_trait::async_trait] impl Provider for SignedOut { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Auth("GitHub sign-in required".into())) } } diff --git a/crates/app/src/desktop.rs b/crates/app/src/desktop.rs index e42fee7..70e220f 100644 --- a/crates/app/src/desktop.rs +++ b/crates/app/src/desktop.rs @@ -9,7 +9,7 @@ use std::{ }; use serde::Serialize; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::{ auth::resolve_token, cache::Cache, @@ -231,7 +231,7 @@ struct SignedOutProvider; #[async_trait::async_trait] impl Provider for SignedOutProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Auth("GitHub sign-in required".to_owned())) } } diff --git a/crates/app/src/runtime.rs b/crates/app/src/runtime.rs index baeb176..e0941ac 100644 --- a/crates/app/src/runtime.rs +++ b/crates/app/src/runtime.rs @@ -1,6 +1,16 @@ -use std::{io, net::SocketAddr, num::NonZeroU64, path::PathBuf, sync::Arc, time::Duration}; +use std::{ + io, + net::SocketAddr, + num::NonZeroU64, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; -use stellr_core::{Model, Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Model, Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::cache::Cache; use stellr_server::{ poll::{PollingControl, spawn_controlled_poller}, @@ -17,25 +27,68 @@ use tokio::{ #[derive(Clone)] pub struct ProviderSlot { current: Arc>>, + generation: Arc, + confirmed_generation: Arc, + publication: Arc>, } impl ProviderSlot { pub fn new(provider: Arc) -> Self { Self { current: Arc::new(RwLock::new(provider)), + generation: Arc::new(AtomicU64::new(0)), + confirmed_generation: Arc::new(AtomicU64::new(0)), + publication: Arc::new(std::sync::Mutex::new(())), } } pub async fn replace(&self, provider: Arc) { - *self.current.write().await = provider; + let mut current = self.current.write().await; + let _publication = self + .publication + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *current = provider; + self.generation.fetch_add(1, Ordering::AcqRel); } } #[async_trait::async_trait] impl Provider for ProviderSlot { - async fn fetch(&self, repo: &RepoRef) -> Result, ProviderError> { - let provider = self.current.read().await.clone(); - provider.fetch(repo).await + async fn fetch(&self, repo: &RepoRef) -> Result { + let (provider, generation) = { + let current = self.current.read().await; + (current.clone(), self.generation.load(Ordering::Acquire)) + }; + let result = provider.fetch(repo).await; + if self.generation.load(Ordering::Acquire) != generation { + return Err(ProviderError::Superseded); + } + if result.is_ok() { + self.confirmed_generation + .store(generation, Ordering::Release); + } + result.map(|snapshot| snapshot.with_publication_generation(generation)) + } + + fn allows_cached_viewer_identity(&self) -> bool { + self.confirmed_generation.load(Ordering::Acquire) == self.generation.load(Ordering::Acquire) + } + + fn commit_if_current(&self, publication_generations: &[u64], commit: &mut dyn FnMut()) -> bool { + let _publication = self + .publication + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let generation = self.generation.load(Ordering::Acquire); + if publication_generations + .iter() + .any(|candidate| *candidate != generation) + { + return false; + } + commit(); + true } } diff --git a/crates/app/tests/application_process_test.rs b/crates/app/tests/application_process_test.rs index 35691cf..aa8cd5d 100644 --- a/crates/app/tests/application_process_test.rs +++ b/crates/app/tests/application_process_test.rs @@ -33,6 +33,7 @@ async fn controlled_github() -> (String, tokio::task::JoinHandle<()>) { post(|| async { Json(json!({ "data": { + "viewer": { "login": "octocat" }, "repository": { "issues": { "pageInfo": { "hasNextPage": false, "endCursor": null }, diff --git a/crates/app/tests/auth_activation_test.rs b/crates/app/tests/auth_activation_test.rs index 8d40674..81de4ef 100644 --- a/crates/app/tests/auth_activation_test.rs +++ b/crates/app/tests/auth_activation_test.rs @@ -4,7 +4,7 @@ use std::sync::{ }; use stellr_app::{auth_activation::activate_provider_and_store, runtime::ProviderSlot}; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::{ credentials::{CredentialStore, CredentialStoreError}, device_flow::AccessToken, @@ -15,7 +15,7 @@ struct SignedOut; #[async_trait::async_trait] impl Provider for SignedOut { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Auth("sign-in required".into())) } } @@ -24,8 +24,8 @@ struct Active; #[async_trait::async_trait] impl Provider for Active { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { - Ok(vec![]) + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::without_viewer(vec![])) } } @@ -69,5 +69,5 @@ async fn storage_failure_warns_after_activating_the_provider_and_refreshing() { owner: "teloverge".into(), name: "stellr".into(), }; - assert_eq!(slot.fetch(&repo).await.unwrap(), vec![]); + assert_eq!(slot.fetch(&repo).await.unwrap().issues, vec![]); } diff --git a/crates/app/tests/desktop_runtime_test.rs b/crates/app/tests/desktop_runtime_test.rs index 1de5055..97e39c6 100644 --- a/crates/app/tests/desktop_runtime_test.rs +++ b/crates/app/tests/desktop_runtime_test.rs @@ -1,15 +1,15 @@ use std::{path::Path, process::Command, sync::Arc}; use stellr_app::desktop::{DesktopRuntimeOptions, start_runtime, start_runtime_with_entry}; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_server::spaces::{SpaceEntry, detect_repo}; struct EmptyProvider; #[async_trait::async_trait] impl Provider for EmptyProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { - Ok(vec![]) + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::without_viewer(vec![])) } } diff --git a/crates/app/tests/provider_activation_test.rs b/crates/app/tests/provider_activation_test.rs index b1eb30b..29a6253 100644 --- a/crates/app/tests/provider_activation_test.rs +++ b/crates/app/tests/provider_activation_test.rs @@ -1,13 +1,15 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; -use stellr_app::runtime::ProviderSlot; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_app::runtime::{ProviderSlot, RuntimeOptions, SessionAuth, start}; +use stellr_core::{IssueState, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef}; +use stellr_github::cache::Cache; +use stellr_server::spaces::{SpaceEntry, SpaceStore}; struct SignedOut; #[async_trait::async_trait] impl Provider for SignedOut { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Auth("sign-in required".into())) } } @@ -16,8 +18,8 @@ struct Active; #[async_trait::async_trait] impl Provider for Active { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { - Ok(vec![]) + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::without_viewer(vec![])) } } @@ -36,5 +38,160 @@ async fn replacing_the_provider_activates_it_in_the_current_process() { slot.replace(Arc::new(Active)).await; - assert_eq!(slot.fetch(&repo).await.unwrap(), vec![]); + assert_eq!(slot.fetch(&repo).await.unwrap().issues, vec![]); +} + +struct DelayedSuccess { + started: Arc, + release: Arc, +} + +#[async_trait::async_trait] +impl Provider for DelayedSuccess { + async fn fetch(&self, _repo: &RepoRef) -> Result { + self.started.notify_one(); + self.release.notified().await; + Ok(ProviderSnapshot::new( + Some("previous-account".into()), + vec![RawIssue { + number: 92, + parent_issue: None, + title: "Previous account work".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec!["previous-account".into()], + milestone: None, + labels: vec!["ready-for-agent".into()], + blocked_by: vec![], + url: "https://github.com/teloverge/stellr/issues/92".into(), + }], + )) + } +} + +#[tokio::test] +async fn replacement_suppresses_cached_identity_until_the_new_provider_succeeds() { + let slot = ProviderSlot::new(Arc::new(Active)); + let repo = RepoRef { + owner: "teloverge".into(), + name: "stellr".into(), + }; + assert!(slot.allows_cached_viewer_identity()); + + slot.replace(Arc::new(SignedOut)).await; + assert!(!slot.allows_cached_viewer_identity()); + assert!(slot.fetch(&repo).await.is_err()); + assert!(!slot.allows_cached_viewer_identity()); + + slot.replace(Arc::new(Active)).await; + assert!(!slot.allows_cached_viewer_identity()); + slot.fetch(&repo).await.unwrap(); + assert!(slot.allows_cached_viewer_identity()); +} + +#[tokio::test] +async fn an_old_in_flight_success_cannot_confirm_a_replacement_generation() { + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let slot = ProviderSlot::new(Arc::new(DelayedSuccess { + started: started.clone(), + release: release.clone(), + })); + let repo = RepoRef { + owner: "teloverge".into(), + name: "stellr".into(), + }; + let started_wait = started.notified(); + let fetching_slot = slot.clone(); + let fetching_repo = repo.clone(); + let fetch = tokio::spawn(async move { fetching_slot.fetch(&fetching_repo).await }); + started_wait.await; + + slot.replace(Arc::new(SignedOut)).await; + release.notify_one(); + assert!(matches!( + fetch.await.unwrap(), + Err(ProviderError::Superseded) + )); + + assert!(!slot.allows_cached_viewer_identity()); +} + +#[tokio::test] +async fn replacement_invalidates_a_fetched_snapshot_before_publication() { + let slot = ProviderSlot::new(Arc::new(Active)); + let repo = RepoRef { + owner: "teloverge".into(), + name: "stellr".into(), + }; + let snapshot = slot.fetch(&repo).await.unwrap(); + let generation = snapshot.publication_generation().unwrap(); + + slot.replace(Arc::new(SignedOut)).await; + + let mut published = false; + let committed = slot.commit_if_current(&[generation], &mut || published = true); + assert!(!committed); + assert!(!published); +} + +#[tokio::test] +async fn a_superseded_fetch_never_reaches_the_live_model_or_cache() { + let profile = tempfile::tempdir().unwrap(); + let repo = RepoRef { + owner: "teloverge".into(), + name: "stellr".into(), + }; + let spaces_file = profile.path().join("spaces.toml"); + let cache_root = profile.path().join("cache"); + let mut spaces = SpaceStore::load(spaces_file.clone()); + spaces.add(SpaceEntry::new(repo.clone(), None)).unwrap(); + spaces.save().unwrap(); + + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let slot = Arc::new(ProviderSlot::new(Arc::new(DelayedSuccess { + started: started.clone(), + release: release.clone(), + }))); + let started_wait = started.notified(); + let runtime = start( + RuntimeOptions { + address: "127.0.0.1:0".into(), + session_auth: SessionAuth::Disabled, + issue: None, + spaces_file, + cache_root: cache_root.clone(), + poll_interval: Duration::from_secs(60), + }, + slot.clone(), + ) + .await + .unwrap(); + let state = runtime.state(); + let mut models = state.hub.subscribe(); + started_wait.await; + + slot.replace(Arc::new(SignedOut)).await; + release.notify_one(); + + tokio::time::timeout(Duration::from_secs(2), models.changed()) + .await + .expect("superseded fetch should publish safe fallback state") + .unwrap(); + let model = models.borrow_and_update().clone(); + assert_eq!(model.spaces.len(), 1); + assert_eq!(model.spaces[0].viewer_login, None); + assert!(model.spaces[0].stars.is_empty()); + assert!(model.spaces[0].stale); + assert!( + model.spaces[0] + .error + .as_deref() + .is_some_and(|error| error.contains("provider changed")) + ); + assert!(Cache::new(cache_root).load(&repo).is_none()); + + runtime.shutdown_handle().shutdown(); + runtime.wait().await.unwrap(); } diff --git a/crates/app/tests/runtime_test.rs b/crates/app/tests/runtime_test.rs index 7751289..5262496 100644 --- a/crates/app/tests/runtime_test.rs +++ b/crates/app/tests/runtime_test.rs @@ -9,7 +9,7 @@ use std::{ }; use stellr_app::runtime::{RuntimeOptions, SessionAuth, start}; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_server::spaces::{SpaceEntry, SpaceStore}; use tempfile::TempDir; use tokio::{sync::Notify, time::timeout}; @@ -29,7 +29,7 @@ impl Drop for FetchDropSignal { #[async_trait::async_trait] impl Provider for PendingProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { let _drop_signal = FetchDropSignal(self.fetch_dropped.clone()); self.fetch_started.notify_one(); pending().await diff --git a/crates/core/src/derive.rs b/crates/core/src/derive.rs index 9aac15e..48ae56a 100644 --- a/crates/core/src/derive.rs +++ b/crates/core/src/derive.rs @@ -28,12 +28,20 @@ pub fn derive(issues: &[RawIssue]) -> Vec { IssueState::Open if has_open_blocker => Status::Blocked, IssueState::Open => Status::Frontier, }; + let ready_for_agent = issue.state == IssueState::Open + && !has_open_blocker + && issue + .labels + .iter() + .any(|label| label.eq_ignore_ascii_case("ready-for-agent")); Star { number: issue.number, parent_issue: issue.parent_issue.filter(|parent| *parent != issue.number), title: issue.title.clone(), status, + ready_for_agent, + blocked: has_open_blocker, blocked_by, milestone: issue.milestone.clone(), labels: issue.labels.clone(), @@ -101,6 +109,36 @@ mod tests { assert_eq!(status_of(&stars, 5), Status::Frontier); } + #[test] + fn keeps_claimed_compatibility_while_exposing_agent_readiness() { + let mut ready_claimed = issue(1, IssueState::Open, &["octocat"], &[], None); + ready_claimed.labels = vec!["READY-FOR-AGENT".into()]; + let mut blocked_claimed = issue(2, IssueState::Open, &["octocat"], &[3], None); + blocked_claimed.labels = vec!["ready-for-agent".into()]; + let blocker = issue(3, IssueState::Open, &[], &[], None); + + let stars = derive(&[ready_claimed, blocked_claimed, blocker]); + + assert_eq!(status_of(&stars, 1), Status::Claimed); + assert!( + stars + .iter() + .find(|star| star.number == 1) + .unwrap() + .ready_for_agent + ); + assert_eq!(status_of(&stars, 2), Status::Claimed); + assert!( + !stars + .iter() + .find(|star| star.number == 2) + .unwrap() + .ready_for_agent + ); + assert!(!stars.iter().find(|star| star.number == 1).unwrap().blocked); + assert!(stars.iter().find(|star| star.number == 2).unwrap().blocked); + } + #[test] fn removes_self_unknown_and_duplicate_blocker_references() { let stars = derive(&[ diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 861e2f1..02051b3 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,4 +6,4 @@ pub mod provider; pub use derive::derive; pub use model::{IssueState, Model, RawIssue, SpaceModel, Star, Status}; -pub use provider::{Provider, ProviderError, RepoRef}; +pub use provider::{Provider, ProviderError, ProviderSnapshot, RepoRef}; diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 27b7a27..75597d7 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -17,6 +17,10 @@ pub struct Star { pub parent_issue: Option, pub title: String, pub status: Status, + #[serde(default)] + pub ready_for_agent: bool, + #[serde(default)] + pub blocked: bool, pub blocked_by: Vec, pub milestone: Option, pub labels: Vec, @@ -53,6 +57,8 @@ pub struct SpaceModel { pub id: String, pub repo: String, pub name: String, + #[serde(default)] + pub viewer_login: Option, pub stars: Vec, pub synced_at: Option, pub stale: bool, @@ -83,11 +89,14 @@ mod tests { id: "abc".into(), repo: "octocat/hello".into(), name: "hello".into(), + viewer_login: Some("octocat".into()), stars: vec![Star { number: 7, parent_issue: Some(16), title: "Fix login".into(), status: Status::Frontier, + ready_for_agent: false, + blocked: false, blocked_by: vec![], milestone: Some("v1".into()), labels: vec!["research".into()], @@ -105,6 +114,10 @@ mod tests { let round_tripped: Model = serde_json::from_str(&json).unwrap(); assert_eq!(round_tripped, model); + assert_eq!( + round_tripped.spaces[0].viewer_login.as_deref(), + Some("octocat") + ); } #[test] @@ -135,6 +148,8 @@ mod tests { let model: Model = serde_json::from_str(old_model).unwrap(); + assert_eq!(model.spaces[0].viewer_login, None); assert_eq!(model.spaces[0].stars[0].parent_issue, None); + assert!(!model.spaces[0].stars[0].blocked); } } diff --git a/crates/core/src/provider.rs b/crates/core/src/provider.rs index 77948b3..881d30c 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -1,5 +1,35 @@ use crate::RawIssue; +#[derive(Debug, Clone)] +pub struct ProviderSnapshot { + pub viewer_login: Option, + pub issues: Vec, + publication_generation: Option, +} + +impl ProviderSnapshot { + pub fn new(viewer_login: Option, issues: Vec) -> Self { + Self { + viewer_login, + issues, + publication_generation: None, + } + } + + pub fn without_viewer(issues: Vec) -> Self { + Self::new(None, issues) + } + + pub fn with_publication_generation(mut self, generation: u64) -> Self { + self.publication_generation = Some(generation); + self + } + + pub fn publication_generation(&self) -> Option { + self.publication_generation + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRef { pub owner: String, @@ -14,7 +44,20 @@ impl RepoRef { #[async_trait::async_trait] pub trait Provider { - async fn fetch(&self, repo: &RepoRef) -> Result, ProviderError>; + async fn fetch(&self, repo: &RepoRef) -> Result; + + fn allows_cached_viewer_identity(&self) -> bool { + true + } + + fn commit_if_current( + &self, + _publication_generations: &[u64], + commit: &mut dyn FnMut(), + ) -> bool { + commit(); + true + } } #[derive(Debug, thiserror::Error)] @@ -27,4 +70,6 @@ pub enum ProviderError { Http(String), #[error("response parsing failed: {0}")] Parse(String), + #[error("provider changed while the request was in flight")] + Superseded, } diff --git a/crates/github/examples/fetch.rs b/crates/github/examples/fetch.rs index d275dd4..50de1d1 100644 --- a/crates/github/examples/fetch.rs +++ b/crates/github/examples/fetch.rs @@ -9,6 +9,10 @@ async fn main() { owner: "teloverge".into(), name: "stellr".into(), }; - let issues = provider.fetch(&repo).await.expect("GitHub fetch failed"); + let issues = provider + .fetch(&repo) + .await + .expect("GitHub fetch failed") + .issues; println!("{}", issues.len()); } diff --git a/crates/github/src/cache.rs b/crates/github/src/cache.rs index ae36b05..07c471c 100644 --- a/crates/github/src/cache.rs +++ b/crates/github/src/cache.rs @@ -13,6 +13,8 @@ static NEXT_ARTIFACT_ID: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Snapshot { + #[serde(default)] + pub viewer_login: Option, pub issues: Vec, pub synced_at: i64, } @@ -253,6 +255,7 @@ mod tests { fn snapshot(title: &str, synced_at: i64) -> Snapshot { Snapshot { + viewer_login: Some("octocat".into()), issues: vec![RawIssue { number: 1, parent_issue: None, @@ -319,6 +322,7 @@ mod tests { let snapshot = cache.load(&repo).unwrap(); + assert_eq!(snapshot.viewer_login, None); assert_eq!(snapshot.issues[0].parent_issue, None); } diff --git a/crates/github/src/sync.rs b/crates/github/src/sync.rs index 6f50d85..bd5e699 100644 --- a/crates/github/src/sync.rs +++ b/crates/github/src/sync.rs @@ -1,7 +1,7 @@ use octocrab::{FromResponse, Octocrab}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use stellr_core::{IssueState, Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{IssueState, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef}; use crate::textref; @@ -9,6 +9,7 @@ const DEFAULT_BASE_URI: &str = "https://api.github.com"; const FETCH_ISSUES_QUERY: &str = r#" query FetchIssues($owner: String!, $name: String!, $cursor: String) { + viewer { login } repository(owner: $owner, name: $name) { issues(first: 100, after: $cursor, states: [OPEN, CLOSED]) { pageInfo { @@ -135,9 +136,10 @@ impl GithubProvider { #[async_trait::async_trait] impl Provider for GithubProvider { - async fn fetch(&self, repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, repo: &RepoRef) -> Result { let mut cursor = None; let mut nodes = Vec::new(); + let mut viewer_login: Option = None; loop { let request = GraphqlRequest { @@ -154,13 +156,29 @@ impl Provider for GithubProvider { let response: GraphqlEnvelope = serde_json::from_value(response) .map_err(|error| ProviderError::Parse(error.to_string()))?; - let connection = response + let data = response .data .ok_or_else(|| ProviderError::Parse("missing data.repository.issues".into())) .and_then(|data| { serde_json::from_value::(data) .map_err(|error| ProviderError::Parse(error.to_string())) - })? + })?; + + if data.viewer.login.trim().is_empty() { + return Err(ProviderError::Parse("viewer login is empty".into())); + } + + match viewer_login.as_deref() { + Some(login) if login != data.viewer.login => { + return Err(ProviderError::Parse( + "viewer login changed during issue pagination".into(), + )); + } + Some(_) => {} + None => viewer_login = Some(data.viewer.login.clone()), + } + + let connection = data .repository .map(|repository| repository.issues) .ok_or_else(|| ProviderError::Parse("missing data.repository.issues".into()))?; @@ -176,7 +194,7 @@ impl Provider for GithubProvider { let mut issues = map_issues(nodes); issues.sort_by_key(|issue| issue.number); - Ok(issues) + Ok(ProviderSnapshot::new(viewer_login, issues)) } } @@ -256,9 +274,15 @@ struct GraphqlEnvelope { #[derive(Deserialize)] struct GraphqlData { + viewer: Viewer, repository: Option, } +#[derive(Deserialize)] +struct Viewer { + login: String, +} + #[derive(Deserialize)] struct Repository { issues: IssueConnection, diff --git a/crates/github/tests/sync_test.rs b/crates/github/tests/sync_test.rs index 0bca715..e88d604 100644 --- a/crates/github/tests/sync_test.rs +++ b/crates/github/tests/sync_test.rs @@ -16,8 +16,20 @@ fn page(nodes: Value) -> Value { } fn page_with_pagination(nodes: Value, has_next_page: bool, end_cursor: Option<&str>) -> Value { + page_for_viewer("octocat", nodes, has_next_page, end_cursor) +} + +fn page_for_viewer( + viewer_login: &str, + nodes: Value, + has_next_page: bool, + end_cursor: Option<&str>, +) -> Value { json!({ "data": { + "viewer": { + "login": viewer_login + }, "repository": { "issues": { "pageInfo": { @@ -83,14 +95,82 @@ async fn fetch_follows_pagination_until_the_repository_is_complete() { .await; let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); - let issues = provider.fetch(&repo()).await.unwrap(); + let result = provider.fetch(&repo()).await.unwrap(); + assert_eq!(result.viewer_login.as_deref(), Some("octocat")); assert_eq!( - issues.iter().map(|issue| issue.number).collect::>(), + result + .issues + .iter() + .map(|issue| issue.number) + .collect::>(), vec![1, 2] ); } +#[tokio::test] +async fn fetch_rejects_missing_viewer_identity_as_a_parse_failure() { + let server = MockServer::start().await; + let mut response = page(json!([])); + response["data"].as_object_mut().unwrap().remove("viewer"); + mount_graphql_response(&server, response).await; + + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + let error = provider.fetch(&repo()).await.unwrap_err(); + + assert!(matches!(error, ProviderError::Parse(_))); + assert!(error.to_string().contains("viewer")); +} + +#[tokio::test] +async fn fetch_rejects_an_empty_viewer_login_as_a_parse_failure() { + for login in ["", " "] { + let server = MockServer::start().await; + mount_graphql_response(&server, page_for_viewer(login, json!([]), false, None)).await; + + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + let error = provider.fetch(&repo()).await.unwrap_err(); + + assert!(matches!(error, ProviderError::Parse(_))); + assert!(error.to_string().contains("viewer login")); + } +} + +#[tokio::test] +async fn fetch_rejects_a_viewer_change_during_pagination() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_partial_json( + json!({ "variables": { "cursor": "CUR1" } }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(page_for_viewer( + "hubot", + json!([]), + false, + None, + ))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(page_with_pagination( + json!([]), + true, + Some("CUR1"), + )), + ) + .mount(&server) + .await; + + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + let error = provider.fetch(&repo()).await.unwrap_err(); + + assert!(matches!(error, ProviderError::Parse(_))); + assert!(error.to_string().contains("viewer login changed")); +} + #[tokio::test] async fn fetch_maps_a_rejected_token_to_auth() { let server = MockServer::start().await; @@ -218,6 +298,7 @@ async fn mount_graphql_response(server: &MockServer, response: Value) { .and(body_string_contains( "issues(first: 100, after: $cursor, states: [OPEN, CLOSED])", )) + .and(body_string_contains("viewer { login }")) .and(body_string_contains("parent { number }")) .respond_with(ResponseTemplate::new(200).set_body_json(response)) .mount(server) @@ -287,7 +368,7 @@ async fn fetch_maps_complete_issue_shape_and_merges_dependency_sources() { .await; let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); - let issues = provider.fetch(&repo()).await.unwrap(); + let issues = provider.fetch(&repo()).await.unwrap().issues; assert_eq!(issues[0].parent_issue, None); assert_eq!(issues[2].parent_issue, Some(16)); @@ -410,7 +491,7 @@ async fn fetch_enriches_markdown_relationship_sections() { .await; let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); - let issues = provider.fetch(&repo()).await.unwrap(); + let issues = provider.fetch(&repo()).await.unwrap().issues; assert_eq!(issues[1].parent_issue, Some(1)); assert_eq!(issues[1].blocked_by, vec![1, 3]); diff --git a/crates/server/src/poll.rs b/crates/server/src/poll.rs index b4ba119..2138d47 100644 --- a/crates/server/src/poll.rs +++ b/crates/server/src/poll.rs @@ -1,7 +1,7 @@ use std::{sync::Arc, time::Duration}; use chrono::Utc; -use stellr_core::{Model, Provider, SpaceModel, derive}; +use stellr_core::{Model, Provider, ProviderError, ProviderSnapshot, SpaceModel, derive}; use stellr_github::cache::{Cache, Snapshot}; use crate::{spaces::SpaceEntry, state::AppState}; @@ -96,44 +96,102 @@ async fn run_poller( async fn sync_spaces(state: &AppState, provider: &(dyn Provider + Send + Sync), cache: &Cache) { let entries = state.spaces.lock().await.entries().to_vec(); - let mut spaces = Vec::with_capacity(entries.len()); - for entry in entries { - spaces.push(sync_space(&entry, provider, cache).await); + let mut results = Vec::with_capacity(entries.len()); + for entry in &entries { + results.push(provider.fetch(&entry.repo).await); } - state.hub.send_replace(Model { spaces }); + let publication_generations: Vec = results + .iter() + .filter_map(|result| { + result + .as_ref() + .ok() + .and_then(ProviderSnapshot::publication_generation) + }) + .collect(); + let mut pending = Some(results); + let mut publish = || { + let spaces = entries + .iter() + .zip(pending.take().expect("provider commit runs once")) + .map(|(entry, result)| sync_result(entry, result, provider, cache)) + .collect(); + state.hub.send_replace(Model { spaces }); + }; + if provider.commit_if_current(&publication_generations, &mut publish) { + return; + } + + let superseded = ProviderError::Superseded.to_string(); + let mut publish_safe_fallback = || { + let spaces = entries + .iter() + .map(|entry| cached_model(entry, provider, cache, superseded.clone())) + .collect(); + state.hub.send_replace(Model { spaces }); + }; + let committed = provider.commit_if_current(&[], &mut publish_safe_fallback); + debug_assert!(committed, "an empty fallback batch must be current"); } -async fn sync_space( +fn sync_result( entry: &SpaceEntry, + result: Result, provider: &(dyn Provider + Send + Sync), cache: &Cache, ) -> SpaceModel { - match provider.fetch(&entry.repo).await { - Ok(issues) => { + match result { + Ok(snapshot) => { let synced_at = Utc::now().timestamp(); // A successful provider sync is fresh even if its fallback cache cannot be updated. let _ = cache.store( &entry.repo, &Snapshot { - issues: issues.clone(), + viewer_login: snapshot.viewer_login.clone(), + issues: snapshot.issues.clone(), synced_at, }, ); - model(entry, issues, Some(synced_at), false, None) - } - Err(error) => { - let snapshot = cache.load(&entry.repo); - let (issues, synced_at) = snapshot - .map(|snapshot| (snapshot.issues, Some(snapshot.synced_at))) - .unwrap_or_default(); - model(entry, issues, synced_at, true, Some(error.to_string())) + model( + entry, + snapshot.issues, + snapshot.viewer_login, + Some(synced_at), + false, + None, + ) } + Err(error) => cached_model(entry, provider, cache, error.to_string()), } } +fn cached_model( + entry: &SpaceEntry, + provider: &(dyn Provider + Send + Sync), + cache: &Cache, + error: String, +) -> SpaceModel { + let snapshot = cache.load(&entry.repo); + let (issues, cached_viewer_login, synced_at) = snapshot + .map(|snapshot| { + ( + snapshot.issues, + snapshot.viewer_login, + Some(snapshot.synced_at), + ) + }) + .unwrap_or_default(); + let viewer_login = provider + .allows_cached_viewer_identity() + .then_some(cached_viewer_login) + .flatten(); + model(entry, issues, viewer_login, synced_at, true, Some(error)) +} + fn model( entry: &SpaceEntry, issues: Vec, + viewer_login: Option, synced_at: Option, stale: bool, error: Option, @@ -142,6 +200,7 @@ fn model( id: entry.id.clone(), repo: entry.repo.slug(), name: entry.repo.name.clone(), + viewer_login, stars: derive(&issues), synced_at, stale, diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index 070a993..93f1ac5 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -8,7 +8,9 @@ use std::{ use futures_util::{SinkExt, StreamExt}; use serde_json::json; -use stellr_core::{IssueState, Model, Provider, ProviderError, RawIssue, RepoRef, SpaceModel}; +use stellr_core::{ + IssueState, Model, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef, SpaceModel, +}; use stellr_github::cache::{Cache, Snapshot}; use stellr_server::{ poll::spawn_poller, @@ -65,6 +67,7 @@ fn model_with_space(id: &str) -> Model { id: id.into(), repo: "owner/repo".into(), name: "repo".into(), + viewer_login: None, stars: vec![], synced_at: None, stale: false, @@ -139,11 +142,11 @@ async fn embedded_ui_does_not_mask_unknown_api_paths() { assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); } -struct StubProvider(Vec); +struct StubProvider(ProviderSnapshot); #[async_trait::async_trait] impl Provider for StubProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Ok(self.0.clone()) } } @@ -152,20 +155,33 @@ struct FailingProvider; #[async_trait::async_trait] impl Provider for FailingProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Http("offline".into())) } } +struct UnconfirmedFailingProvider; + +#[async_trait::async_trait] +impl Provider for UnconfirmedFailingProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result { + Err(ProviderError::Http("replacement offline".into())) + } + + fn allows_cached_viewer_identity(&self) -> bool { + false + } +} + struct SequenceProvider(AtomicUsize); #[async_trait::async_trait] impl Provider for SequenceProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { if self.0.fetch_add(1, Ordering::SeqCst) == 0 { - return Ok(vec![]); + return Ok(ProviderSnapshot::without_viewer(vec![])); } - Ok(vec![RawIssue { + Ok(ProviderSnapshot::without_viewer(vec![RawIssue { number: 9, parent_issue: None, title: "Arrived on the second tick".into(), @@ -176,7 +192,7 @@ impl Provider for SequenceProvider { labels: vec![], blocked_by: vec![], url: "https://github.com/o/r/issues/9".into(), - }]) + }])) } } @@ -192,18 +208,21 @@ async fn add_repo_space_immediately_populates_the_model() { }); let poller = spawn_poller( state.clone(), - Arc::new(StubProvider(vec![RawIssue { - number: 1, - parent_issue: None, - title: "Ready work".into(), - body: String::new(), - state: IssueState::Open, - assignees: vec![], - milestone: None, - labels: vec![], - blocked_by: vec![], - url: "https://github.com/o/r/issues/1".into(), - }])), + Arc::new(StubProvider(ProviderSnapshot::new( + Some("octocat".into()), + vec![RawIssue { + number: 1, + parent_issue: None, + title: "Ready work".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec!["OctoCat".into()], + milestone: None, + labels: vec!["ready-for-agent".into()], + blocked_by: vec![], + url: "https://github.com/o/r/issues/1".into(), + }], + ))), Cache::new(directory.path().join("cache")), Duration::from_secs(60), ); @@ -250,7 +269,9 @@ async fn add_repo_space_immediately_populates_the_model() { assert_eq!(model.spaces[0].id, "o-r"); assert_eq!(model.spaces[0].repo, "o/r"); + assert_eq!(model.spaces[0].viewer_login.as_deref(), Some("octocat")); assert_eq!(model.spaces[0].stars[0].number, 1); + assert!(model.spaces[0].stars[0].ready_for_agent); poller.abort(); } @@ -266,6 +287,7 @@ async fn failed_sync_publishes_the_cached_model_as_stale_with_the_error() { .store( &repo, &Snapshot { + viewer_login: Some("octocat".into()), issues: vec![RawIssue { number: 7, parent_issue: None, @@ -321,6 +343,7 @@ async fn failed_sync_publishes_the_cached_model_as_stale_with_the_error() { .expect("failed sync should still publish the cached model"); assert_eq!(model.spaces[0].stars[0].number, 7); + assert_eq!(model.spaces[0].viewer_login.as_deref(), Some("octocat")); assert_eq!(model.spaces[0].synced_at, Some(1_753_000_000)); assert!(model.spaces[0].stale); assert_eq!( @@ -330,6 +353,63 @@ async fn failed_sync_publishes_the_cached_model_as_stale_with_the_error() { poller.abort(); } +#[tokio::test] +async fn failed_unconfirmed_provider_keeps_cached_issues_but_suppresses_cached_viewer() { + let directory = tempfile::tempdir().unwrap(); + let repo = RepoRef { + owner: "o".into(), + name: "r".into(), + }; + let cache = Cache::new(directory.path().join("cache")); + cache + .store( + &repo, + &Snapshot { + viewer_login: Some("previous-account".into()), + issues: vec![RawIssue { + number: 7, + parent_issue: None, + title: "Cached work".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec!["previous-account".into()], + milestone: None, + labels: vec!["ready-for-agent".into()], + blocked_by: vec![], + url: "https://github.com/o/r/issues/7".into(), + }], + synced_at: 1_753_000_000, + }, + ) + .unwrap(); + let mut spaces = SpaceStore::load(directory.path().join("spaces.toml")); + spaces.add(SpaceEntry::new(repo, None)).unwrap(); + let (hub, mut receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); + let state = Arc::new(AppState { + hub, + token: None, + spaces: tokio::sync::Mutex::new(spaces), + refresh: Arc::new(tokio::sync::Notify::new()), + }); + let poller = spawn_poller( + state, + Arc::new(UnconfirmedFailingProvider), + cache, + Duration::from_secs(60), + ); + + tokio::time::timeout(Duration::from_secs(1), receiver.changed()) + .await + .expect("the stale snapshot should publish") + .expect("the model hub should remain open"); + let model = receiver.borrow_and_update().clone(); + + assert_eq!(model.spaces[0].viewer_login, None); + assert_eq!(model.spaces[0].stars[0].number, 7); + assert!(model.spaces[0].stale); + poller.abort(); +} + #[tokio::test] async fn successful_sync_stays_fresh_when_the_cache_cannot_be_written() { let directory = tempfile::tempdir().unwrap(); @@ -354,7 +434,7 @@ async fn successful_sync_stays_fresh_when_the_cache_cannot_be_written() { }); let poller = spawn_poller( state, - Arc::new(StubProvider(vec![])), + Arc::new(StubProvider(ProviderSnapshot::without_viewer(vec![]))), Cache::new(cache_root), Duration::from_secs(60), ); diff --git a/crates/server/tests/focus_polling_test.rs b/crates/server/tests/focus_polling_test.rs index 2d0e869..77dffd5 100644 --- a/crates/server/tests/focus_polling_test.rs +++ b/crates/server/tests/focus_polling_test.rs @@ -3,7 +3,7 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, }; -use stellr_core::{Model, Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Model, Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::cache::Cache; use stellr_server::{ poll::{PollingControl, spawn_controlled_poller}, @@ -15,9 +15,9 @@ struct CountingProvider(Arc); #[async_trait::async_trait] impl Provider for CountingProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { self.0.fetch_add(1, Ordering::SeqCst); - Ok(vec![]) + Ok(ProviderSnapshot::without_viewer(vec![])) } } diff --git a/docs/superpowers/plans/2026-08-08-project-layout-cache-and-transition.md b/docs/superpowers/plans/2026-08-08-project-layout-cache-and-transition.md new file mode 100644 index 0000000..4533d4d --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-project-layout-cache-and-transition.md @@ -0,0 +1,492 @@ +# Project Layout Cache and Transition Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make project selection immediate and responsive by computing uncached deterministic layouts in a cancellable worker, showing a timed first-load state, caching successful coordinates for the session, and restoring the last successful project after cancellation or critical failure. + +**Architecture:** Add a deep `LayoutLoader` module whose small interface returns either cached coordinates or a cancellable pending result. A dedicated Vite module worker preserves the existing pure layout geometry off the main thread. The Svelte star-map wrapper owns loading/error presentation and request lifecycle, while `App.svelte` owns requested-versus-committed routing and rollback. + +**Tech Stack:** Svelte 5 runes, TypeScript 6, Vite module workers, Vitest/jsdom, existing canvas `StarMap`, native Windows PowerShell, Vite+ (`vp`), Rust/Cargo workspace verification. + +## Global Constraints + +- Work only in `D:\tmp\stellr-issues-91-96` on `codex/issues-91-96-work-priority`; preserve the dirty primary checkout. +- Preserve the existing deterministic `computeLayout` output exactly; do not optimize or retune cluster geometry in this slice. +- Cache only successful coordinate snapshots for the current browser session, keyed by the exact existing `structureSignature`. +- An uncached request must run off the browser main thread and remain immediately cancellable. +- Route/sidebar selection changes optimistically; only success changes the committed project. +- Cancel and critical failure restore the last successful project. With no successful project, Cancel shows Canceled plus Retry and critical failure shows Error plus Retry. +- Ignore every canceled or superseded result, even when worker completion races termination. +- Maintain the append-only, newest-first `CHANGELOG.md` structure under `Unreleased`. +- Use native Windows commands and executables only; do not use WSL or Linux toolchains. +- Preserve the six existing uncommitted Rust review-fix files and stage only files belonging to each task. + +--- + +## File Map + +- Create `web/src/lib/starmap/layout-loader.ts`: cache, worker adapter interface, result validation, cancellation, and browser worker factory. +- Create `web/src/lib/starmap/layout-loader.test.ts`: real interface tests using a controlled worker adapter. +- Create `web/src/lib/starmap/layout.worker.ts`: worker entry point invoking the existing pure `computeLayout`. +- Modify `web/src/lib/starmap/starmap.ts`: accept already-computed positions when applying a new structure. +- Modify `web/src/lib/starmap/starmap.test.ts`: preserve synchronous fallback tests and prove supplied coordinates bypass computation. +- Create `web/src/lib/LayoutTransition.svelte`: accessible loading/stopwatch/Cancel and canceled/error/Retry presentation. +- Create `web/src/lib/LayoutTransition.test.ts`: copy, accessibility, actions, and stopwatch rendering. +- Modify `web/src/lib/StarMap.svelte`: coordinate async layout requests, timers, stale-result suppression, renderer application, and callbacks. +- Modify `web/src/lib/StarMap.test-host.svelte`: expose controlled prop transitions required by wrapper tests. +- Modify `web/src/lib/StarMap.test.ts`: loading, cache hit, timer, cancellation, retry, failure, and cleanup tests. +- Modify `web/src/App.svelte`: committed-project tracking and rollback policy. +- Modify `web/src/App.test.ts`: optimistic selection and application-level rollback tests with a controlled loader. +- Modify `CHANGELOG.md`: add the pending behavior to `Unreleased`. + +--- + +### Task 1: Cancellable worker-backed session layout cache + +**Files:** +- Create: `web/src/lib/starmap/layout-loader.ts` +- Create: `web/src/lib/starmap/layout-loader.test.ts` +- Create: `web/src/lib/starmap/layout.worker.ts` + +**Interfaces:** +- Consumes: `LayoutNode`, `Point`, `computeLayout`, and `structureSignature` from `web/src/lib/starmap/layout.ts`. +- Produces: + +```ts +export type LayoutPoints = Record + +export type LayoutOutcome = + | { kind: 'ready'; points: LayoutPoints } + | { kind: 'cancelled' } + | { kind: 'failed'; message: string } + +export type LayoutLoad = + | { kind: 'cached'; signature: string; points: LayoutPoints } + | { + kind: 'pending' + signature: string + result: Promise + cancel(): void + } + +export interface LayoutRequester { + load(nodes: LayoutNode[]): LayoutLoad +} + +export interface LayoutWorkerPort { + onmessage: ((event: MessageEvent) => void) | null + onerror: ((event: ErrorEvent) => void) | null + postMessage(message: { nodes: LayoutNode[] }): void + terminate(): void +} + +export class LayoutLoader implements LayoutRequester { + constructor(workerFactory: () => LayoutWorkerPort) + load(nodes: LayoutNode[]): LayoutLoad +} + +export const browserLayoutLoader: LayoutRequester +``` + +- [ ] **Step 1: Write failing cache and lifecycle tests** + +Create a controlled `LayoutWorkerPort` that records `postMessage`/`terminate` and can emit arbitrary messages or errors. Add separate tests proving: + +```ts +it('returns a defensive cached result after one successful worker layout') +it('does not invalidate coordinates for status-only data outside LayoutNode') +it('uses a new worker when structure or an orbit title changes') +it('terminates and resolves cancelled without caching') +it('turns worker errors and malformed coordinates into failed outcomes') +it('ignores a ready message that races after cancellation') +``` + +Use finite coordinates for every requested node as the validity rule. Prove defensive copying by mutating the first returned point and asserting the later cache hit retains the original value. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```powershell +vp exec vitest run src/lib/starmap/layout-loader.test.ts --reporter=verbose +``` + +Expected: FAIL because `layout-loader.ts` and its interface do not exist. + +- [ ] **Step 3: Implement the minimal loader and worker** + +Implement `LayoutLoader.load` as follows: + +1. Compute `structureSignature(nodes)`. +2. Return cloned cached points synchronously when present. +3. Create one dedicated worker for a miss. +4. Return a pending result whose `cancel()` terminates once and resolves `{ kind: 'cancelled' }`. +5. On a valid success message, clone into the cache, terminate, and resolve ready with another clone. +6. On construction error, worker error, explicit failure message, missing node, or non-finite coordinate, terminate and resolve failed. +7. Guard every terminal path with one settled flag so racing messages do nothing. + +The worker receives `{ nodes }`, calls `computeLayout(nodes)`, and posts either: + +```ts +{ kind: 'ready', points } +{ kind: 'failed', message: String(error) } +``` + +Create the browser adapter with Vite's statically analyzable worker URL: + +```ts +new Worker(new URL('./layout.worker.ts', import.meta.url), { type: 'module' }) +``` + +- [ ] **Step 4: Run focused tests and frontend typecheck** + +Run: + +```powershell +vp exec vitest run src/lib/starmap/layout-loader.test.ts --reporter=verbose +vp run check +``` + +Expected: all focused tests PASS and Svelte/TypeScript report zero errors. + +- [ ] **Step 5: Commit Task 1** + +```powershell +git add -- web/src/lib/starmap/layout-loader.ts web/src/lib/starmap/layout-loader.test.ts web/src/lib/starmap/layout.worker.ts +git commit -m "feat(web): cache cancellable project layouts" +``` + +--- + +### Task 2: Let the renderer apply prepared deterministic coordinates + +**Files:** +- Modify: `web/src/lib/starmap/starmap.ts:350-430` +- Modify: `web/src/lib/starmap/starmap.test.ts` + +**Interfaces:** +- Consumes: `LayoutPoints` from Task 1. +- Produces this compatible renderer seam: + +```ts +setModel( + tickets: Ticket[], + sessions?: Record, + currentIssue?: number | null, + preparedLayout?: LayoutPoints, +): void +``` + +The fourth argument applies only when the structure signature changes. Existing direct renderer consumers may omit it and retain the synchronous deterministic fallback. + +- [ ] **Step 1: Write the failing renderer test** + +Add a test that spies on `computeLayout`, supplies distinctive finite points through the fourth argument, and asserts: + +```ts +expect(computeLayout).not.toHaveBeenCalled() +expect(sm.positions()).toEqual(preparedPoints) +``` + +Also repush status-only tickets without a fourth argument and prove the prepared positions remain unchanged. + +- [ ] **Step 2: Run the focused renderer test and verify RED** + +Run: + +```powershell +vp exec vitest run src/lib/starmap/starmap.test.ts -t "prepared deterministic coordinates" --reporter=verbose +``` + +Expected: FAIL because `setModel` ignores or does not accept prepared positions. + +- [ ] **Step 3: Implement the minimal compatible renderer change** + +At the existing structure-change branch, replace the unconditional layout call with: + +```ts +const pts = preparedLayout ?? computeLayout(layoutNodes) +``` + +Keep signature calculation, node construction, edge refresh, selection clearing, and camera refit unchanged. Do not move cache or worker knowledge into the renderer. + +- [ ] **Step 4: Run the complete star-map renderer suite** + +Run: + +```powershell +vp exec vitest run src/lib/starmap/starmap.test.ts src/lib/starmap/layout.test.ts src/lib/starmap/cluster-layout.test.ts --reporter=verbose +``` + +Expected: all tests PASS with identical existing geometry assertions. + +- [ ] **Step 5: Commit Task 2** + +```powershell +git add -- web/src/lib/starmap/starmap.ts web/src/lib/starmap/starmap.test.ts +git commit -m "feat(web): apply prepared constellation layouts" +``` + +--- + +### Task 3: Present responsive timed loading, Cancel, and Retry + +**Files:** +- Create: `web/src/lib/LayoutTransition.svelte` +- Create: `web/src/lib/LayoutTransition.test.ts` +- Modify: `web/src/lib/StarMap.svelte` +- Modify: `web/src/lib/StarMap.test-host.svelte` +- Modify: `web/src/lib/StarMap.test.ts` + +**Interfaces:** +- Consumes: `LayoutRequester`, `LayoutLoad`, and `LayoutOutcome` from Task 1; prepared renderer coordinates from Task 2. +- `StarMap.svelte` adds optional injected `layout` with default `browserLayoutLoader` and callbacks: + +```ts +layout?: LayoutRequester +ready?: (spaceId: string) => void +cancelled?: (spaceId: string) => void +failed?: (spaceId: string, message: string) => void +``` + +- `LayoutTransition.svelte` accepts: + +```ts +kind: 'loading' | 'cancelled' | 'error' +projectName: string +elapsedSeconds?: number +message?: string +cancel?: () => void +retry?: () => void +``` + +- [ ] **Step 1: Write failing presentation tests** + +Use fake timers and a controlled `LayoutRequester`. Add separate tests proving: + +```ts +it('shows Charting, the first-load message, 0 seconds, and accessible Cancel on a miss') +it('increments visible elapsed seconds once per second without a live timer announcement') +it('renders a cached layout without showing the transition') +it('cancels the active request and reports the project id') +it('suppresses a superseded request result') +it('applies ready coordinates and reports the project id') +it('shows Error and Retry after an unhandled initial failure') +it('shows Canceled and Retry when cancellation does not navigate away') +it('clears its interval and cancels work when destroyed') +``` + +The controlled requester must return cached points or expose a pending outcome resolver without mocking the Svelte component itself. + +- [ ] **Step 2: Run wrapper and transition tests and verify RED** + +Run: + +```powershell +vp exec vitest run src/lib/LayoutTransition.test.ts src/lib/StarMap.test.ts --reporter=verbose +``` + +Expected: FAIL because the transition module and asynchronous wrapper behavior are absent. + +- [ ] **Step 3: Implement `LayoutTransition.svelte`** + +Render the exact loading copy: + +```text +Charting {projectName}... +First load may take a moment. {elapsedSeconds} seconds elapsed. +Cancel +``` + +Use `role="status"` and `aria-live="polite"` for the stable title/message. Put the ticking value in a separate element with `aria-live="off"`. Use real ` {/if} + {#if layoutFailureNotice !== null} + + {/if}