Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/app/src/acceptance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -26,7 +26,7 @@ struct SignedOut;

#[async_trait::async_trait]
impl Provider for SignedOut {
async fn fetch(&self, _repo: &RepoRef) -> Result<Vec<RawIssue>, ProviderError> {
async fn fetch(&self, _repo: &RepoRef) -> Result<ProviderSnapshot, ProviderError> {
Err(ProviderError::Auth("GitHub sign-in required".into()))
}
}
Expand Down
4 changes: 2 additions & 2 deletions crates/app/src/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -231,7 +231,7 @@ struct SignedOutProvider;

#[async_trait::async_trait]
impl Provider for SignedOutProvider {
async fn fetch(&self, _repo: &RepoRef) -> Result<Vec<RawIssue>, ProviderError> {
async fn fetch(&self, _repo: &RepoRef) -> Result<ProviderSnapshot, ProviderError> {
Err(ProviderError::Auth("GitHub sign-in required".to_owned()))
}
}
Expand Down
65 changes: 59 additions & 6 deletions crates/app/src/runtime.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand All @@ -17,25 +27,68 @@ use tokio::{
#[derive(Clone)]
pub struct ProviderSlot {
current: Arc<RwLock<Arc<dyn Provider + Send + Sync>>>,
generation: Arc<AtomicU64>,
confirmed_generation: Arc<AtomicU64>,
publication: Arc<std::sync::Mutex<()>>,
}

impl ProviderSlot {
pub fn new(provider: Arc<dyn Provider + Send + Sync>) -> 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<dyn Provider + Send + Sync>) {
*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<Vec<RawIssue>, ProviderError> {
let provider = self.current.read().await.clone();
provider.fetch(repo).await
async fn fetch(&self, repo: &RepoRef) -> Result<ProviderSnapshot, ProviderError> {
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
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/app/tests/application_process_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
10 changes: 5 additions & 5 deletions crates/app/tests/auth_activation_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,7 +15,7 @@ struct SignedOut;

#[async_trait::async_trait]
impl Provider for SignedOut {
async fn fetch(&self, _repo: &RepoRef) -> Result<Vec<RawIssue>, ProviderError> {
async fn fetch(&self, _repo: &RepoRef) -> Result<ProviderSnapshot, ProviderError> {
Err(ProviderError::Auth("sign-in required".into()))
}
}
Expand All @@ -24,8 +24,8 @@ struct Active;

#[async_trait::async_trait]
impl Provider for Active {
async fn fetch(&self, _repo: &RepoRef) -> Result<Vec<RawIssue>, ProviderError> {
Ok(vec![])
async fn fetch(&self, _repo: &RepoRef) -> Result<ProviderSnapshot, ProviderError> {
Ok(ProviderSnapshot::without_viewer(vec![]))
}
}

Expand Down Expand Up @@ -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![]);
}
6 changes: 3 additions & 3 deletions crates/app/tests/desktop_runtime_test.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<RawIssue>, ProviderError> {
Ok(vec![])
async fn fetch(&self, _repo: &RepoRef) -> Result<ProviderSnapshot, ProviderError> {
Ok(ProviderSnapshot::without_viewer(vec![]))
}
}

Expand Down
171 changes: 164 additions & 7 deletions crates/app/tests/provider_activation_test.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<RawIssue>, ProviderError> {
async fn fetch(&self, _repo: &RepoRef) -> Result<ProviderSnapshot, ProviderError> {
Err(ProviderError::Auth("sign-in required".into()))
}
}
Expand All @@ -16,8 +18,8 @@ struct Active;

#[async_trait::async_trait]
impl Provider for Active {
async fn fetch(&self, _repo: &RepoRef) -> Result<Vec<RawIssue>, ProviderError> {
Ok(vec![])
async fn fetch(&self, _repo: &RepoRef) -> Result<ProviderSnapshot, ProviderError> {
Ok(ProviderSnapshot::without_viewer(vec![]))
}
}

Expand All @@ -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<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
}

#[async_trait::async_trait]
impl Provider for DelayedSuccess {
async fn fetch(&self, _repo: &RepoRef) -> Result<ProviderSnapshot, ProviderError> {
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();
}
Loading