diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fa827c..110ed95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - build-essential curl file libayatana-appindicator3-dev librsvg2-dev \ + build-essential curl file libayatana-appindicator3-dev libdbus-1-dev librsvg2-dev \ libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf wget - run: npm --prefix web ci - run: npm --prefix web run build @@ -33,6 +33,9 @@ jobs: - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace - run: cargo build --workspace + - name: Validate the optional native desktop feature + run: cargo test -p stellr-app --all-targets --features desktop + - run: cargo build -p stellr-app --features desktop web: runs-on: ubuntu-latest diff --git a/.github/workflows/linux-bundle.yml b/.github/workflows/linux-bundle.yml index bf1e61d..58b2365 100644 --- a/.github/workflows/linux-bundle.yml +++ b/.github/workflows/linux-bundle.yml @@ -29,6 +29,7 @@ jobs: curl \ file \ libayatana-appindicator3-dev \ + libdbus-1-dev \ librsvg2-dev \ libssl-dev \ libwebkit2gtk-4.1-dev \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d6ffa6..cdd5f4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,9 +32,9 @@ jobs: shell: powershell run: Get-ChildItem scripts\tests -Filter *.tests.ps1 | ForEach-Object { & $_.FullName } - run: cargo fmt --check - - run: cargo clippy --workspace --all-targets -- -D warnings - - run: cargo test --workspace - - run: cargo build --workspace + - run: cargo clippy --workspace --all-targets --features stellr-app/desktop -- -D warnings + - run: cargo test --workspace --features stellr-app/desktop + - run: cargo build --workspace --features stellr-app/desktop - name: Require the tag to match the application version shell: powershell run: | @@ -168,7 +168,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - build-essential curl file libayatana-appindicator3-dev librsvg2-dev \ + build-essential curl file libayatana-appindicator3-dev libdbus-1-dev librsvg2-dev \ libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf wget - run: npm --prefix web ci - name: Build Linux release packages diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/AGENTS.md b/AGENTS.md index cb370dc..1bd84fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,12 @@ # Repository instructions -Assume a native Windows 11 development environment. - -- Do not use WSL, Linux shells, Linux toolchains, or `/mnt/*` paths for development, builds, tests, benchmarks, or validation. -- Run commands with native Windows PowerShell or `cmd.exe` from Windows paths such as `D:\dev`. -- Use native Windows executables and toolchains (`cargo.exe`, `rustc.exe`, `bun.exe`, and `powershell.exe`). -- If native Windows command execution is unavailable, stop and report the environment limitation instead of substituting WSL results. -- Maintain release notes as an append-only, newest-first changelog with a separate section for every shipped version and an `Unreleased` section for pending work. -- Never fold new changes into an older release section or repeat cumulative changes under every version. +- Keep Windows and macOS packaging checks platform-specific. Do not replace + their native CI runners with WSL or cross-compilation for release evidence. +- Maintain release notes as an append-only, newest-first changelog with a + separate section for every shipped version and an `Unreleased` section for + pending work. +- Never fold new changes into an older release section or repeat cumulative + changes under every version. ## Agent skills diff --git a/CHANGELOG.md b/CHANGELOG.md index 0424ee7..eade774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## Unreleased +- Kept the authenticated Tailnet browser session stable across launcher-driven + rebuilds and restarts so existing browser tabs can reconnect, and replaced + endless retries for invalid sessions with an explicit expiration message; + rebuilt executable inodes are now recognized when replacing the old server. +- Added an npm-accessible Tailnet launcher that safely replaces the prior + instance and binds the optimized web server to the current Tailscale IPv4 + address with session authentication enabled, while retaining the latest + authenticated URL in an owner-only local file. +- Added Linux shell, Windows PowerShell, and Windows Command Prompt helpers for + retrieving that authenticated URL from the `amd-halo` Tailnet host over SSH. +- Added a Debian/Ubuntu dependency installer for optional native desktop and + package builds using Tauri, WebKitGTK, D-Bus, and app indicators. +- Updated the locked DOMPurify and Nano ID dependencies to versions that pass + the npm security audit. +- Added `.nvmrc`, npm package metadata, and strict engine checks requiring + Node.js 24 with npm 12.0.2 before installing the web workspace. +- Made the browser-hosted server the default Linux development build, with + Tauri and OS credential storage isolated behind an explicit desktop feature. - Suspended star-map GPU rendering while the app is minimized or its browser document is hidden, while retaining five-minute native background polling. - Made project changes immediate and responsive: first-time constellation @@ -19,6 +37,9 @@ activated it across CI, bundle, and release workflows. - Made newly added repositories appear in the sidebar without restarting Stellr or performing another space action. +- Clarified active-work priority with dimensional issue nodes, a maximum of two + status/selection rings, compact arrows, and literal `in-progress` label + support ahead of `ready-for-agent` and blocked work. - Emphasized the incoming and outgoing edges directly connected to a selected node while preserving dependency direction, state styling, and motion. - Replaced compact subissue arcs with adaptive, label-aware concentric orbits, @@ -33,6 +54,25 @@ - Split the Windows desktop and CLI entry points so installed shortcuts and protocol launches open without a terminal while `stellr serve` retains native PowerShell and Command Prompt behavior. +- Hardened temporal playback with provider-backed verification cutoffs, + cutoff-safe cursors, native offline restarts, durable import resume, migration + rollback, space-scoped deletion, bounded rate-limit retry, neutral historical + workflow styling, accessible status text, focus, and narrow layouts. +- Added delta-only background history synchronization using ordinary snapshot + metadata, resumable per-issue cursors, catch-up verification, rate-limit reset + evidence, idempotent client merging, and a pinned-past New activity action. +- Added fixed-duration full-history playback with proportional clustered event + ticks, accessible play/pause and speed controls, slow-frame-safe event + feedback, and reduced-motion captions that preserve map position and selection. +- Added exact milestone assignment, movement, and removal replay with minimal + GitHub event payloads and temporal milestone hulls that reshape around fixed + star coordinates without moving the constellation or camera. +- Added resumable, one-issue-at-a-time close/reopen history import with + transactional page checkpoints, stable lifecycle replay, and schema migration + from creation-only ledgers without repository-wide history refetches. +- Added a durable local SQLite issue-creation ledger, authenticated history + deltas, and a bottom timeline that scrubs historical issue visibility without + moving the constellation or querying GitHub during scrubbing. - Fixed installed desktop startup so a no-argument launch opens the existing empty repository-selection shell instead of treating the installation directory as a Git repository. diff --git a/CLAUDE.md b/CLAUDE.md index cb370dc..0a33757 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,8 @@ # Repository instructions -Assume a native Windows 11 development environment. - -- Do not use WSL, Linux shells, Linux toolchains, or `/mnt/*` paths for development, builds, tests, benchmarks, or validation. -- Run commands with native Windows PowerShell or `cmd.exe` from Windows paths such as `D:\dev`. -- Use native Windows executables and toolchains (`cargo.exe`, `rustc.exe`, `bun.exe`, and `powershell.exe`). -- If native Windows command execution is unavailable, stop and report the environment limitation instead of substituting WSL results. +- Keep Windows and macOS packaging checks on their native CI runners; do not + substitute WSL or cross-compilation for release evidence. +- Use the repository's pinned Node/npm toolchain and the native Rust toolchain. - Maintain release notes as an append-only, newest-first changelog with a separate section for every shipped version and an `Unreleased` section for pending work. - Never fold new changes into an older release section or repeat cumulative changes under every version. diff --git a/Cargo.lock b/Cargo.lock index 6df20b1..35d582e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1475,6 +1475,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -2053,12 +2065,30 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.4.1" @@ -2722,6 +2752,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4152,6 +4193,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.19", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -4855,6 +4921,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4874,6 +4952,7 @@ dependencies = [ "serde_json", "stellr-core", "stellr-github", + "stellr-history", "stellr-server", "tauri", "tauri-build", @@ -4896,6 +4975,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.19", + "tokio", ] [[package]] @@ -4918,6 +4998,17 @@ dependencies = [ "wiremock", ] +[[package]] +name = "stellr-history" +version = "0.1.0" +dependencies = [ + "rusqlite", + "serde_json", + "stellr-core", + "tempfile", + "thiserror 2.0.19", +] + [[package]] name = "stellr-server" version = "0.1.0" @@ -4934,6 +5025,7 @@ dependencies = [ "serde_json", "stellr-core", "stellr-github", + "stellr-history", "subtle", "tempfile", "thiserror 2.0.19", diff --git a/Cargo.toml b/Cargo.toml index cabb2c6..8125e08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/core", + "crates/history", "crates/github", "crates/server", "crates/app", @@ -19,6 +20,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "sync-secret-service"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rusqlite = { version = "0.38", features = ["bundled"] } tauri = { version = "2.11.5", features = ["tray-icon"] } tauri-build = "2.6.3" tauri-plugin-dialog = "2.7.2" diff --git a/README.md b/README.md index 482c74c..1909d0f 100644 --- a/README.md +++ b/README.md @@ -7,81 +7,121 @@ relationships become edges, milestones become clusters, and completed work becomes resolved stars. The native shell and the browser-hosted serve mode use the same Rust application runtime. -**Status: M2 native shell available.** Windows 11, macOS, and Linux desktop -packages are built on native CI runners. The approved product design remains in -[`docs/specs/2026-07-29-stellr-port-design.md`](docs/specs/2026-07-29-stellr-port-design.md). +**Status: M2 browser server and optional native shell available.** The headless +server is the primary Linux development target; native desktop packages remain +available through explicit platform builds. The approved product design remains +in [`docs/specs/2026-07-29-stellr-port-design.md`](docs/specs/2026-07-29-stellr-port-design.md). ## Build from source -Install stable Rust, Node.js 24 with npm, and GitHub CLI. From the repository -root in PowerShell: +Linux is the primary local development environment. The default build is a +headless Rust server that embeds the web UI and does not link Tauri, GTK, or +WebKitGTK. Install stable Rust, Node.js 24 with npm 12.0.2, and GitHub CLI. The +repository includes `.nvmrc`; with nvm, install the pinned Node major before +running npm commands: -```powershell -npm.cmd --prefix web ci -npm.cmd --prefix web run build -cargo.exe build -p stellr-app +```bash +nvm install +nvm use +npm install --global npm@12.0.2 ``` -## Desktop mode +The web workspace also declares strict Node/npm engines, so `npm ci` fails +early if the required versions are not active. From the repository root: -A bare launch opens the native desktop shell and restores the last valid route: +```bash +npm --prefix web ci +npm --prefix web run build +cargo build -p stellr-app --bin stellr +``` + +The same commands work in other POSIX shells; use `npm`, `cargo`, and `bash` +directly rather than Windows-specific `.cmd` or `.exe` command names. + +Run the local validation suite with: -```powershell -cargo.exe run -p stellr-app +```bash +npm --prefix web run check +npm --prefix web test +npm --prefix web run build +npm --prefix web audit +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +cargo build --workspace --locked ``` -Open a repository, issue URL, local checkout, or Stellr protocol target -explicitly: +If the web audit reports fixable vulnerabilities, apply lockfile updates from +the repository root with `npm --prefix web audit fix`; the root project has no +Node lockfile, so running plain `npm audit fix` there will fail. -```powershell -cargo.exe run -p stellr-app -- open teloverge/stellr -cargo.exe run -p stellr-app -- open https://github.com/teloverge/stellr/issues/70 -cargo.exe run -p stellr-app -- open D:\dev\stellr +## Linux server and web UI + +Authenticate with `gh auth login` or set `GITHUB_TOKEN`, then start the server: + +```bash +cargo run -p stellr-app --bin stellr -- serve +cargo run -p stellr-app --bin stellr -- serve --addr 127.0.0.1:0 --issue 70 ``` -Packaged installations use the same commands through the `stellr` executable. -Only one desktop process runs at a time; later invocations forward their target -to the existing window, restore it if minimized, focus it, and exit. +Open the printed `stellr cockpit` URL in a browser. The executable serves the +embedded Svelte application, REST API, and control WebSocket from one process. +Protected routes require the generated session token by default. `--no-token` +is an explicit local-development option; do not expose that listener to +another host. -## Credential precedence +To bind the optimized server only to this machine's current Tailscale IPv4 +address, run: -GitHub provider credentials resolve in this order: +```bash +npm --prefix web run serve:tailnet +``` -1. a nonblank `GITHUB_TOKEN` environment variable; -2. the token returned by `gh auth token`; -3. the operating-system credential store entry for service `stellr.github` and - account `default`; -4. an unauthenticated desktop state that offers device authorization. +This launcher keeps session-token authentication enabled. Override its default +port with `STELLR_PORT=9000 npm --prefix web run serve:tailnet`. Starting it +again gracefully replaces this repository's existing Stellr server on the same +Tailnet address and port, while reusing its existing protected session so open +browser tabs can reconnect after a rebuild. The latest complete authenticated URL is also saved +with owner-only permissions in `target/stellr-tailnet-url.txt`, so it can be +retrieved from another computer over SSH and pasted directly into a browser. +Cross-platform retrieval helpers default to the Tailnet host `amd-halo`: + +```bash +bash scripts/get-stellr-tailnet-url.sh +``` -The provider credential is separate from the random per-run browser session -token printed by serve mode. Neither token is exposed to the webview URL. +Windows Command Prompt and PowerShell users can run +`scripts\get-stellr-tailnet-url.cmd` or +`powershell -File scripts\get-stellr-tailnet-url.ps1`. Set +`STELLR_SSH_USER` to override the default SSH user `pfdev`, or pass another +hostname as the first argument. The client must be authorized to SSH to that +account on `amd-halo` (using an SSH key, password, or Tailscale SSH policy). -## Device authorization +The default headless build resolves GitHub credentials in this order: -When desktop mode cannot resolve a credential, it still opens the native shell -and presents **Connect GitHub**. Start authorization, open the supplied GitHub -verification URL in the system browser, and enter the one-time code. Stellr -requests the approved `repo` scope, activates synchronization immediately, and -stores the resulting credential in the operating-system credential store. If -storage fails, the current run remains connected and the shell reports that the -next launch will require sign-in again. +1. a nonblank `GITHUB_TOKEN` environment variable; +2. the token returned by `gh auth token`. -Serve mode does not start device authorization because it has no trusted native -interaction surface; configure one of the first three credential sources before -starting it. +The provider credential is separate from the random per-run browser session +token printed by serve mode. -## Serve mode +## Optional desktop mode -Serve the same embedded application over loopback for a browser or IDE pane: +Native desktop development is an explicit feature and is not required for the +Linux server. On Debian or Ubuntu, install its Tauri dependencies and run it +with: -```powershell -cargo.exe run -p stellr-app -- serve -cargo.exe run -p stellr-app -- serve --addr 127.0.0.1:0 --issue 70 +```bash +bash scripts/install-linux-dependencies.sh +cargo run -p stellr-app --features desktop --bin stellr-desktop +cargo run -p stellr-app --features desktop --bin stellr-desktop -- \ + open teloverge/stellr ``` -Open the printed `stellr cockpit` URL. Protected API and control-WebSocket -routes require the generated session token by default. `--no-token` is an -explicit local-development option; do not expose that listener to another host. +Desktop mode additionally enables OS credential persistence, device +authorization, deep links, single-instance routing, and the native tray. Run a +Secret Service provider such as GNOME Keyring when testing credential storage +on Linux. ## Deep links and single-instance routing diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 764b636..803ccf8 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -13,6 +13,20 @@ path = "src/main.rs" [[bin]] name = "stellr-desktop" path = "src/desktop_main.rs" +required-features = ["desktop"] + +[features] +default = [] +desktop = [ + "dep:tauri", + "dep:tauri-build", + "dep:tauri-plugin-deep-link", + "dep:tauri-plugin-dialog", + "dep:tauri-plugin-opener", + "dep:tauri-plugin-single-instance", + "dep:tauri-plugin-window-state", + "stellr-github/os-credentials", +] [dependencies] async-trait.workspace = true @@ -23,13 +37,14 @@ serde.workspace = true serde_json.workspace = true stellr-core = { path = "../core" } stellr-github = { path = "../github" } +stellr-history = { path = "../history" } stellr-server = { path = "../server" } -tauri.workspace = true -tauri-plugin-deep-link.workspace = true -tauri-plugin-dialog.workspace = true -tauri-plugin-single-instance.workspace = true -tauri-plugin-window-state.workspace = true -tauri-plugin-opener.workspace = true +tauri = { workspace = true, optional = true, features = ["test"] } +tauri-plugin-deep-link = { workspace = true, optional = true } +tauri-plugin-dialog = { workspace = true, optional = true } +tauri-plugin-single-instance = { workspace = true, optional = true } +tauri-plugin-window-state = { workspace = true, optional = true } +tauri-plugin-opener = { workspace = true, optional = true } thiserror.workspace = true tokio.workspace = true url.workspace = true @@ -37,9 +52,8 @@ url.workspace = true [dev-dependencies] async-trait.workspace = true reqwest = "0.12" -tauri = { workspace = true, features = ["test"] } tempfile = "3" tokio.workspace = true [build-dependencies] -tauri-build.workspace = true +tauri-build = { workspace = true, optional = true } diff --git a/crates/app/build.rs b/crates/app/build.rs index 2daa8e3..1afe7dd 100644 --- a/crates/app/build.rs +++ b/crates/app/build.rs @@ -1,4 +1,10 @@ fn main() { + #[cfg(feature = "desktop")] + build_desktop(); +} + +#[cfg(feature = "desktop")] +fn build_desktop() { tauri_build::try_build(tauri_build::Attributes::new().app_manifest( tauri_build::AppManifest::new().commands(&[ "begin_device_authorization", diff --git a/crates/app/src/cli.rs b/crates/app/src/cli.rs index af61951..c6993bc 100644 --- a/crates/app/src/cli.rs +++ b/crates/app/src/cli.rs @@ -1,5 +1,5 @@ use std::num::NonZeroU64; -#[cfg(debug_assertions)] +#[cfg(all(debug_assertions, feature = "desktop"))] use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; @@ -9,6 +9,7 @@ use clap::{Args, Parser, Subcommand}; pub struct Cli { #[command(subcommand)] pub command: Option, + #[cfg(feature = "desktop")] #[arg( value_name = "STELLR_LINK", hide = true, @@ -17,6 +18,7 @@ pub struct Cli { pub protocol_target: Option, } +#[cfg(feature = "desktop")] fn parse_protocol_target(value: &str) -> Result { if value.starts_with("stellr://") { Ok(value.to_owned()) @@ -27,14 +29,15 @@ fn parse_protocol_target(value: &str) -> Result { #[derive(Subcommand)] pub enum Command { - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, feature = "desktop"))] #[command(hide = true)] Acceptance(AcceptanceArgs), Serve(ServeArgs), + #[cfg(feature = "desktop")] Open(OpenArgs), } -#[cfg(debug_assertions)] +#[cfg(all(debug_assertions, feature = "desktop"))] #[derive(Args)] pub struct AcceptanceArgs { #[arg(long)] @@ -43,6 +46,7 @@ pub struct AcceptanceArgs { pub profile: PathBuf, } +#[cfg(feature = "desktop")] #[derive(Args)] pub struct OpenArgs { pub target: String, diff --git a/crates/app/src/desktop_main.rs b/crates/app/src/desktop_main.rs index fef37f0..82e6c6a 100644 --- a/crates/app/src/desktop_main.rs +++ b/crates/app/src/desktop_main.rs @@ -4,9 +4,6 @@ )] fn main() { - #[cfg(not(all(target_os = "windows", not(debug_assertions))))] - let result = stellr_app::entrypoints::run_cli(); - #[cfg(all(target_os = "windows", not(debug_assertions)))] let result = stellr_app::entrypoints::run_desktop(); if let Err(_error) = result { diff --git a/crates/app/src/entrypoints.rs b/crates/app/src/entrypoints.rs index f7c4f2a..22ea9b8 100644 --- a/crates/app/src/entrypoints.rs +++ b/crates/app/src/entrypoints.rs @@ -1,17 +1,21 @@ -use std::{ffi::OsString, io::Write, path::PathBuf, sync::Arc, time::Duration}; +#[cfg(feature = "desktop")] +use std::{ffi::OsString, path::PathBuf}; +use std::{io::Write, sync::Arc, time::Duration}; use clap::Parser; use stellr_github::{auth::resolve_token, cache::Cache, sync::GithubProvider}; use stellr_server::spaces::SpaceStore; +#[cfg(feature = "desktop")] +use crate::desktop::{self, DesktopLaunch}; use crate::{ cli::{Cli, Command, ServeArgs}, - desktop::{self, DesktopLaunch}, runtime::{RuntimeOptions, SessionAuth, start}, }; pub type DynError = Box; +#[cfg(feature = "desktop")] fn effective_launch_dir( current: PathBuf, appimage: Option, @@ -27,6 +31,7 @@ fn effective_launch_dir( current } +#[cfg(feature = "desktop")] fn launch_current_dir() -> std::io::Result { Ok(effective_launch_dir( std::env::current_dir()?, @@ -35,6 +40,7 @@ fn launch_current_dir() -> std::io::Result { )) } +#[cfg(feature = "desktop")] fn desktop_launch_from_cli(cli: Cli, cwd: PathBuf) -> Result { match cli.command { Some(Command::Open(args)) => Ok(DesktopLaunch { @@ -49,10 +55,10 @@ fn desktop_launch_from_cli(cli: Cli, cwd: PathBuf) -> Result Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "serve is available through stellr.exe", + "serve is available through the stellr CLI executable", ) .into()), - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, feature = "desktop"))] Some(Command::Acceptance(_)) => Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "acceptance is available through stellr.exe", @@ -61,6 +67,7 @@ fn desktop_launch_from_cli(cli: Cli, cwd: PathBuf) -> Result(args: I, cwd: PathBuf) -> Result where I: IntoIterator, @@ -69,6 +76,7 @@ where desktop_launch_from_cli(Cli::try_parse_from(args)?, cwd) } +#[cfg(feature = "desktop")] pub fn run_desktop() -> Result<(), DynError> { let launch = desktop_launch_from(std::env::args_os(), launch_current_dir()?)?; desktop::run(launch).map_err(Into::into) @@ -77,7 +85,7 @@ pub fn run_desktop() -> Result<(), DynError> { pub fn run_cli() -> Result<(), DynError> { let cli = Cli::parse(); match cli { - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, feature = "desktop"))] Cli { command: Some(Command::Acceptance(args)), .. @@ -92,24 +100,47 @@ pub fn run_cli() -> Result<(), DynError> { .enable_all() .build()? .block_on(serve(args)), + #[cfg(feature = "desktop")] desktop_cli => { let launch = desktop_launch_from_cli(desktop_cli, launch_current_dir()?)?; desktop::run(launch).map_err(Into::into) } + #[cfg(not(feature = "desktop"))] + Cli { command: None } => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the headless build requires the `serve` command", + ) + .into()), } } async fn serve(args: ServeArgs) -> Result<(), DynError> { let provider_token = resolve_token()?; let provider = Arc::new(GithubProvider::new(provider_token)?); + let session_auth = if args.no_token { + SessionAuth::Disabled + } else if let Some(token) = std::env::var_os("STELLR_SESSION_TOKEN") { + let token = token.into_string().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "STELLR_SESSION_TOKEN must be valid UTF-8", + ) + })?; + if token.len() != 32 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "STELLR_SESSION_TOKEN must be exactly 32 hexadecimal characters", + ) + .into()); + } + SessionAuth::Fixed(token.to_ascii_lowercase()) + } else { + SessionAuth::Required + }; let runtime = start( RuntimeOptions { address: args.addr, - session_auth: if args.no_token { - SessionAuth::Disabled - } else { - SessionAuth::Required - }, + session_auth, issue: args.issue, spaces_file: SpaceStore::default_file(), cache_root: Cache::default_root(), @@ -127,13 +158,17 @@ async fn serve(args: ServeArgs) -> Result<(), DynError> { #[cfg(test)] mod tests { - use std::{num::NonZeroU64, path::PathBuf}; + use std::num::NonZeroU64; + #[cfg(feature = "desktop")] + use std::path::PathBuf; use clap::Parser; + #[cfg(feature = "desktop")] use super::{desktop_launch_from, effective_launch_dir}; use crate::cli::{Cli, Command}; + #[cfg(feature = "desktop")] #[test] fn desktop_entry_accepts_bare_open_and_protocol_launches() { let cwd = PathBuf::from(r"D:\Apps\Stellr"); @@ -158,6 +193,7 @@ mod tests { ); } + #[cfg(feature = "desktop")] #[test] fn desktop_entry_rejects_console_only_serve() { let error = desktop_launch_from( @@ -167,9 +203,10 @@ mod tests { .err() .expect("the desktop entry must reject console-only serve"); - assert!(error.to_string().contains("stellr.exe")); + assert!(error.to_string().contains("stellr CLI")); } + #[cfg(feature = "desktop")] #[test] fn bare_launch_selects_desktop_mode_without_a_repository_target() { let launch = @@ -217,6 +254,7 @@ mod tests { assert!(Cli::try_parse_from(["stellr", "serve", "--issue", "0"]).is_err()); } + #[cfg(feature = "desktop")] #[test] fn open_accepts_one_path_url_slug_or_stellr_target() { for target in [ @@ -235,6 +273,7 @@ mod tests { assert!(Cli::try_parse_from(["stellr", "open", "one", "two"]).is_err()); } + #[cfg(feature = "desktop")] #[test] fn a_registered_stellr_protocol_link_is_accepted_as_the_hidden_root_target() { let link = "stellr://space?repo=teloverge%2Fstellr&issue=62"; @@ -249,6 +288,7 @@ mod tests { assert!(Cli::try_parse_from(["stellr", "not-a-command"]).is_err()); } + #[cfg(feature = "desktop")] #[test] fn appimage_launch_uses_the_callers_original_working_directory() { let mounted = std::env::temp_dir().join("mounted-appimage").join("usr"); @@ -264,6 +304,7 @@ mod tests { ); } + #[cfg(feature = "desktop")] #[test] fn unpackaged_launch_ignores_an_unpaired_original_working_directory() { let current = std::env::temp_dir().join("stellr-repository"); @@ -275,6 +316,7 @@ mod tests { ); } + #[cfg(feature = "desktop")] #[test] fn appimage_launch_rejects_a_relative_original_working_directory() { let mounted = std::env::temp_dir().join("mounted-appimage").join("usr"); diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 5bc4d29..fa024f5 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -1,9 +1,11 @@ //! Shared application assembly for Stellr hosts. -#[cfg(debug_assertions)] +#[cfg(all(debug_assertions, feature = "desktop"))] pub mod acceptance; +#[cfg(feature = "desktop")] pub mod auth_activation; pub mod cli; +#[cfg(feature = "desktop")] pub mod desktop; pub mod entrypoints; pub mod route_state; diff --git a/crates/app/src/runtime.rs b/crates/app/src/runtime.rs index e0941ac..c0bc3b6 100644 --- a/crates/app/src/runtime.rs +++ b/crates/app/src/runtime.rs @@ -10,8 +10,11 @@ use std::{ time::Duration, }; -use stellr_core::{Model, Provider, ProviderError, ProviderSnapshot, RepoRef}; +use stellr_core::{ + HistoryPage, HistoryPageRequest, Model, Provider, ProviderError, ProviderSnapshot, RepoRef, +}; use stellr_github::cache::Cache; +use stellr_history::HistoryStore; use stellr_server::{ poll::{PollingControl, spawn_controlled_poller}, routes::router, @@ -90,11 +93,25 @@ impl Provider for ProviderSlot { commit(); true } + + async fn fetch_snapshot(&self, repo: &RepoRef) -> Result { + self.fetch(repo).await + } + + async fn fetch_history_page( + &self, + repo: &RepoRef, + request: &HistoryPageRequest, + ) -> Result { + let provider = self.current.read().await.clone(); + provider.fetch_history_page(repo, request).await + } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionAuth { Required, + Fixed(String), Disabled, } @@ -196,6 +213,8 @@ pub enum RuntimeError { Server(#[source] io::Error), #[error("application poller task failed: {0}")] PollerTask(#[source] tokio::task::JoinError), + #[error("could not open temporal history: {0}")] + History(#[from] stellr_history::StoreError), } pub async fn start( @@ -217,15 +236,18 @@ pub async fn start_with_polling( let address = listener.local_addr().map_err(RuntimeError::LocalAddress)?; let session_token = match options.session_auth { SessionAuth::Required => Some(session_token().map_err(RuntimeError::SessionToken)?), + SessionAuth::Fixed(token) => Some(token), SessionAuth::Disabled => None, }; let spaces = SpaceStore::load(options.spaces_file); + let history = HistoryStore::open(options.cache_root.join("history.sqlite3"))?; let (hub, _receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); let state = Arc::new(AppState { hub, token: session_token.clone(), spaces: tokio::sync::Mutex::new(spaces), refresh: Arc::new(tokio::sync::Notify::new()), + history, }); let poller = spawn_controlled_poller( state.clone(), diff --git a/crates/app/tests/application_process_test.rs b/crates/app/tests/application_process_test.rs index aa8cd5d..31a71be 100644 --- a/crates/app/tests/application_process_test.rs +++ b/crates/app/tests/application_process_test.rs @@ -1,7 +1,9 @@ +#![cfg(feature = "desktop")] + use std::process::Stdio; use axum::{Json, Router, routing::post}; -use serde_json::json; +use serde_json::{Value, json}; use tokio::process::Command; async fn controlled_github() -> (String, tokio::task::JoinHandle<()>) { @@ -30,15 +32,50 @@ async fn controlled_github() -> (String, tokio::task::JoinHandle<()>) { ) .route( "/graphql", - post(|| async { + post(|Json(request): Json| async move { + let query = request["query"].as_str().unwrap_or_default(); + if query.contains("FetchIssueHistory") { + return Json(json!({ + "data": { + "repository": { + "id": "R_stellr", + "issue": { + "id": "I_70", + "timelineItems": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [ + { + "__typename": "ClosedEvent", + "id": "E_close_70", + "createdAt": "2026-08-04T12:15:00Z" + }, + { + "__typename": "ReopenedEvent", + "id": "E_reopen_70", + "createdAt": "2026-08-04T12:30:00Z" + } + ] + } + } + } + } + })); + } Json(json!({ "data": { "viewer": { "login": "octocat" }, "repository": { + "id": "R_stellr", "issues": { "pageInfo": { "hasNextPage": false, "endCursor": null }, "nodes": [{ + "id": "I_70", "number": 70, + "createdAt": "2026-08-04T12:00:00Z", + "updatedAt": "2026-08-04T13:00:00Z", "title": "M2 controlled process evidence", "body": "", "url": "https://github.com/teloverge/stellr/issues/70", diff --git a/crates/app/tests/auth_activation_test.rs b/crates/app/tests/auth_activation_test.rs index 81de4ef..865151b 100644 --- a/crates/app/tests/auth_activation_test.rs +++ b/crates/app/tests/auth_activation_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "desktop")] + use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, diff --git a/crates/app/tests/desktop_runtime_test.rs b/crates/app/tests/desktop_runtime_test.rs index 97e39c6..df4cfda 100644 --- a/crates/app/tests/desktop_runtime_test.rs +++ b/crates/app/tests/desktop_runtime_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "desktop")] + use std::{path::Path, process::Command, sync::Arc}; use stellr_app::desktop::{DesktopRuntimeOptions, start_runtime, start_runtime_with_entry}; diff --git a/crates/app/tests/desktop_window_test.rs b/crates/app/tests/desktop_window_test.rs index 21c1ff3..07448c9 100644 --- a/crates/app/tests/desktop_window_test.rs +++ b/crates/app/tests/desktop_window_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "desktop")] + use stellr_app::desktop::create_main_window; #[test] diff --git a/crates/app/tests/runtime_test.rs b/crates/app/tests/runtime_test.rs index 5262496..fef4ad8 100644 --- a/crates/app/tests/runtime_test.rs +++ b/crates/app/tests/runtime_test.rs @@ -3,13 +3,16 @@ use std::{ num::NonZeroU64, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }, time::Duration, }; use stellr_app::runtime::{RuntimeOptions, SessionAuth, start}; -use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; +use stellr_core::{ + HistoryPage, HistoryPageRequest, IssueState, IssueSyncMetadata, Provider, ProviderError, + ProviderSnapshot, RawIssue, RepoRef, +}; use stellr_server::spaces::{SpaceEntry, SpaceStore}; use tempfile::TempDir; use tokio::{sync::Notify, time::timeout}; @@ -21,6 +24,12 @@ struct PendingProvider { struct FetchDropSignal(Arc); +struct CompleteHistoryProvider { + history_requests: AtomicUsize, +} + +struct OfflineProvider; + impl Drop for FetchDropSignal { fn drop(&mut self) { self.0.store(true, Ordering::SeqCst); @@ -36,6 +45,73 @@ impl Provider for PendingProvider { } } +fn historical_issue() -> RawIssue { + RawIssue { + number: 7, + parent_issue: None, + title: "Persisted issue".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec![], + milestone: None, + labels: vec![], + blocked_by: vec![], + url: "https://github.com/teloverge/stellr/issues/7".into(), + } +} + +#[async_trait::async_trait] +impl Provider for CompleteHistoryProvider { + async fn fetch(&self, repo: &RepoRef) -> Result { + self.fetch_snapshot(repo).await + } + + async fn fetch_snapshot(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::with_history( + None, + Some("R_stellr".into()), + Some(1_753_000_000), + vec![historical_issue()], + vec![IssueSyncMetadata { + issue_id: "I_7".into(), + number: 7, + created_at: 1_752_000_000, + updated_at: 1_752_000_000, + milestone: None, + }], + )) + } + + async fn fetch_history_page( + &self, + _repo: &RepoRef, + _request: &HistoryPageRequest, + ) -> Result { + self.history_requests.fetch_add(1, Ordering::SeqCst); + Ok(HistoryPage { + events: vec![], + next_cursor: None, + resume_cursor: Some("CUR_END".into()), + complete: true, + }) + } +} + +#[async_trait::async_trait] +impl Provider for OfflineProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result { + Err(ProviderError::Http("offline".into())) + } + + async fn fetch_history_page( + &self, + _repo: &RepoRef, + _request: &HistoryPageRequest, + ) -> Result { + panic!("an offline restart must not refetch a completed timeline") + } +} + fn options(profile: &TempDir) -> RuntimeOptions { RuntimeOptions { address: "127.0.0.1:0".into(), @@ -113,3 +189,89 @@ async fn runtime_exposes_its_loopback_contract_and_stops_server_and_poller() { "shutdown should stop accepting HTTP connections" ); } + +#[tokio::test] +async fn complete_history_survives_a_native_runtime_restart_while_offline() { + let profile = tempfile::tempdir().unwrap(); + let mut spaces = SpaceStore::load(profile.path().join("spaces.toml")); + spaces + .add(SpaceEntry::new( + RepoRef { + owner: "teloverge".into(), + name: "stellr".into(), + }, + None, + )) + .unwrap(); + spaces.save().unwrap(); + + let provider = Arc::new(CompleteHistoryProvider { + history_requests: AtomicUsize::new(0), + }); + let runtime = start(options(&profile), provider.clone()).await.unwrap(); + timeout(Duration::from_secs(5), async { + loop { + if runtime + .state() + .history + .summary("teloverge-stellr") + .unwrap() + .is_some_and(|summary| summary.state == stellr_core::HistoryImportState::Complete) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the first run should persist a complete history ledger"); + assert_eq!(provider.history_requests.load(Ordering::SeqCst), 1); + assert_eq!( + runtime + .state() + .history + .events_after("teloverge-stellr", 0) + .unwrap() + .len(), + 1 + ); + runtime.shutdown_handle().shutdown(); + runtime.wait().await.unwrap(); + + let restarted = start(options(&profile), Arc::new(OfflineProvider)) + .await + .unwrap(); + timeout(Duration::from_secs(5), async { + loop { + let model = restarted.state().hub.borrow().clone(); + if model + .spaces + .first() + .is_some_and(|space| space.stale && space.error.is_some()) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the offline restart should publish its cached current snapshot"); + let summary = restarted + .state() + .history + .summary("teloverge-stellr") + .unwrap() + .unwrap(); + assert_eq!(summary.state, stellr_core::HistoryImportState::Complete); + assert_eq!( + restarted + .state() + .history + .events_after("teloverge-stellr", 0) + .unwrap() + .len(), + 1 + ); + restarted.shutdown_handle().shutdown(); + restarted.wait().await.unwrap(); +} diff --git a/crates/app/tests/serve_test.rs b/crates/app/tests/serve_test.rs index 4fc2a7d..17d7973 100644 --- a/crates/app/tests/serve_test.rs +++ b/crates/app/tests/serve_test.rs @@ -7,9 +7,13 @@ use tokio::{ time::{Duration, timeout}, }; -async fn launch(arguments: &[&str]) -> (Child, String, TempDir) { +async fn launch_with_session_token( + arguments: &[&str], + session_token: Option<&str>, +) -> (Child, String, TempDir) { let profile = tempfile::tempdir().unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_stellr")) + let mut command = Command::new(env!("CARGO_BIN_EXE_stellr")); + command .args(arguments) .env("APPDATA", profile.path().join("roaming")) .env("LOCALAPPDATA", profile.path().join("local")) @@ -19,9 +23,11 @@ async fn launch(arguments: &[&str]) -> (Child, String, TempDir) { .env("GITHUB_TOKEN", "test-provider-token") .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .unwrap(); + .kill_on_drop(true); + if let Some(session_token) = session_token { + command.env("STELLR_SESSION_TOKEN", session_token); + } + let mut child = command.spawn().unwrap(); let stdout = child.stdout.take().unwrap(); let line = timeout( Duration::from_secs(10), @@ -34,6 +40,10 @@ async fn launch(arguments: &[&str]) -> (Child, String, TempDir) { (child, line, profile) } +async fn launch(arguments: &[&str]) -> (Child, String, TempDir) { + launch_with_session_token(arguments, None).await +} + #[tokio::test] async fn serve_without_session_auth_hosts_the_ui_and_empty_model() { let (mut child, line, _profile) = launch(&[ @@ -115,3 +125,20 @@ async fn serve_generates_a_session_token_and_gates_protected_routes_by_default() child.kill().await.unwrap(); } + +#[tokio::test] +async fn serve_reuses_a_configured_session_token() { + let expected = "0123456789abcdef0123456789abcdef"; + let (mut child, line, _profile) = + launch_with_session_token(&["serve", "--addr", "127.0.0.1:0"], Some(expected)).await; + let url = line + .strip_prefix("stellr cockpit: ") + .expect("the startup line should use the documented prefix"); + let token = reqwest::Url::parse(url) + .unwrap() + .query_pairs() + .find_map(|(name, value)| (name == "token").then(|| value.into_owned())); + + assert_eq!(token.as_deref(), Some(expected)); + child.kill().await.unwrap(); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index c3b94cd..bbb919b 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,3 +12,4 @@ thiserror.workspace = true [dev-dependencies] serde_json.workspace = true +tokio.workspace = true diff --git a/crates/core/src/history.rs b/crates/core/src/history.rs new file mode 100644 index 0000000..e85beb8 --- /dev/null +++ b/crates/core/src/history.rs @@ -0,0 +1,103 @@ +use std::cmp::Ordering; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MilestoneRef { + pub id: Option, + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HistoryEventKind { + IssueCreated { + milestone: Option, + }, + IssueClosed, + IssueReopened, + MilestoneChanged { + from: Option, + to: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HistoryEvent { + pub sequence: u64, + pub repository_id: String, + pub issue_id: String, + pub issue_number: u64, + pub provider_event_id: String, + pub occurred_at: i64, + #[serde(flatten)] + pub kind: HistoryEventKind, +} + +impl HistoryEvent { + pub fn creation_id(issue_id: &str) -> String { + format!("{issue_id}:issue_created") + } +} + +impl Ord for HistoryEvent { + fn cmp(&self, other: &Self) -> Ordering { + (self.occurred_at, self.provider_event_id.as_str()) + .cmp(&(other.occurred_at, other.provider_event_id.as_str())) + } +} + +impl PartialOrd for HistoryEvent { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HistoryImportState { + #[default] + Unavailable, + Building, + Complete, + Delayed, + RateLimited, + Failed, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HistorySummary { + pub state: HistoryImportState, + pub completed_issues: u64, + pub total_issues: u64, + pub earliest_event_at: Option, + pub verified_through: Option, + pub revision: u64, + pub diagnostic: Option, + pub resume_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IssueSyncMetadata { + pub issue_id: String, + pub number: u64, + pub created_at: i64, + pub updated_at: i64, + pub milestone: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryPageRequest { + pub issue_id: String, + pub issue_number: u64, + pub cursor: Option, + pub cutoff: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryPage { + pub events: Vec, + pub next_cursor: Option, + pub resume_cursor: Option, + pub complete: bool, +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 02051b3..9e065f9 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,9 +1,14 @@ //! stellr-core: pure issue-graph domain. pub mod derive; +pub mod history; pub mod model; pub mod provider; pub use derive::derive; +pub use history::{ + HistoryEvent, HistoryEventKind, HistoryImportState, HistoryPage, HistoryPageRequest, + HistorySummary, IssueSyncMetadata, MilestoneRef, +}; pub use model::{IssueState, Model, RawIssue, SpaceModel, Star, Status}; pub use provider::{Provider, ProviderError, ProviderSnapshot, RepoRef}; diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 75597d7..55c22e6 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::HistorySummary; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Status { @@ -63,6 +65,8 @@ pub struct SpaceModel { pub synced_at: Option, pub stale: bool, pub error: Option, + #[serde(default)] + pub history: HistorySummary, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -107,6 +111,7 @@ mod tests { synced_at: Some(1_753_000_000), stale: false, error: None, + history: HistorySummary::default(), }], }; diff --git a/crates/core/src/provider.rs b/crates/core/src/provider.rs index 881d30c..9955e5e 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -1,9 +1,13 @@ -use crate::RawIssue; +use crate::{HistoryPage, HistoryPageRequest, IssueSyncMetadata, RawIssue}; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct ProviderSnapshot { pub viewer_login: Option, + pub repository_id: Option, + /// Provider-backed boundary through which history can be claimed complete. + pub history_cutoff: Option, pub issues: Vec, + pub history: Vec, publication_generation: Option, } @@ -11,7 +15,27 @@ impl ProviderSnapshot { pub fn new(viewer_login: Option, issues: Vec) -> Self { Self { viewer_login, + repository_id: None, + history_cutoff: None, issues, + history: Vec::new(), + publication_generation: None, + } + } + + pub fn with_history( + viewer_login: Option, + repository_id: Option, + history_cutoff: Option, + issues: Vec, + history: Vec, + ) -> Self { + Self { + viewer_login, + repository_id, + history_cutoff, + issues, + history, publication_generation: None, } } @@ -46,6 +70,20 @@ impl RepoRef { pub trait Provider { async fn fetch(&self, repo: &RepoRef) -> Result; + async fn fetch_snapshot(&self, repo: &RepoRef) -> Result { + self.fetch(repo).await + } + + async fn fetch_history_page( + &self, + _repo: &RepoRef, + _request: &HistoryPageRequest, + ) -> Result { + Err(ProviderError::Unsupported( + "provider does not supply temporal history".into(), + )) + } + fn allows_cached_viewer_identity(&self) -> bool { true } @@ -72,4 +110,6 @@ pub enum ProviderError { Parse(String), #[error("provider changed while the request was in flight")] Superseded, + #[error("unsupported provider operation: {0}")] + Unsupported(String), } diff --git a/crates/core/tests/model_contract_test.rs b/crates/core/tests/model_contract_test.rs new file mode 100644 index 0000000..e9f4ac0 --- /dev/null +++ b/crates/core/tests/model_contract_test.rs @@ -0,0 +1,132 @@ +use stellr_core::{ + HistoryEvent, HistoryEventKind, HistoryImportState, HistorySummary, IssueSyncMetadata, + MilestoneRef, Model, Provider, ProviderError, ProviderSnapshot, RepoRef, SpaceModel, +}; + +struct CurrentOnlyProvider; + +#[async_trait::async_trait] +impl Provider for CurrentOnlyProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::without_viewer(Vec::new())) + } +} + +#[tokio::test] +async fn current_only_providers_get_a_backward_compatible_snapshot() { + let snapshot = CurrentOnlyProvider + .fetch_snapshot(&RepoRef { + owner: "octocat".into(), + name: "hello".into(), + }) + .await + .unwrap(); + + assert_eq!(snapshot, ProviderSnapshot::without_viewer(Vec::new())); +} + +#[test] +fn creation_events_serialize_with_minimal_provider_evidence() { + let event = HistoryEvent { + sequence: 0, + repository_id: "R_repo".into(), + issue_id: "I_issue".into(), + issue_number: 78, + provider_event_id: HistoryEvent::creation_id("I_issue"), + occurred_at: 1_754_300_000, + kind: HistoryEventKind::IssueCreated { + milestone: Some(MilestoneRef { + id: Some("M_v1".into()), + title: "v1".into(), + }), + }, + }; + + assert_eq!(event.provider_event_id, "I_issue:issue_created"); + assert_eq!( + serde_json::to_value(&event).unwrap(), + serde_json::json!({ + "sequence": 0, + "repository_id": "R_repo", + "issue_id": "I_issue", + "issue_number": 78, + "provider_event_id": "I_issue:issue_created", + "occurred_at": 1_754_300_000, + "kind": "issue_created", + "milestone": { "id": "M_v1", "title": "v1" } + }) + ); +} + +#[test] +fn events_have_a_stable_timestamp_then_provider_id_order() { + let event = |provider_event_id: &str, occurred_at| HistoryEvent { + sequence: 0, + repository_id: "R_repo".into(), + issue_id: "I_issue".into(), + issue_number: 78, + provider_event_id: provider_event_id.into(), + occurred_at, + kind: HistoryEventKind::IssueClosed, + }; + let mut events = [event("z", 4), event("b", 3), event("a", 3)]; + + events.sort(); + + assert_eq!( + events + .iter() + .map(|event| (event.occurred_at, event.provider_event_id.as_str())) + .collect::>(), + vec![(3, "a"), (3, "b"), (4, "z")] + ); +} + +#[test] +fn old_models_default_to_unavailable_history() { + let old = serde_json::json!({ + "spaces": [{ + "id": "abc", + "repo": "octocat/hello", + "name": "hello", + "stars": [], + "synced_at": null, + "stale": false, + "error": null + }] + }); + + let model: Model = serde_json::from_value(old).unwrap(); + + assert_eq!(model.spaces[0].history, HistorySummary::default()); + assert_eq!( + model.spaces[0].history.state, + HistoryImportState::Unavailable + ); +} + +#[test] +fn history_metadata_stays_out_of_the_current_issue_shape() { + let metadata = IssueSyncMetadata { + issue_id: "I_issue".into(), + number: 78, + created_at: 1_754_300_000, + updated_at: 1_754_300_100, + milestone: None, + }; + + assert_eq!(metadata.number, 78); + assert_eq!(metadata.milestone, None); + + let _space_shape = SpaceModel { + id: "abc".into(), + repo: "octocat/hello".into(), + name: "hello".into(), + viewer_login: None, + stars: Vec::new(), + synced_at: None, + stale: false, + error: None, + history: HistorySummary::default(), + }; +} diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 26302e6..92befbd 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -5,11 +5,15 @@ edition.workspace = true license.workspace = true repository.workspace = true +[features] +default = [] +os-credentials = ["dep:keyring"] + [dependencies] async-trait.workspace = true chrono = { version = "0.4", features = ["serde"] } directories = "6" -keyring.workspace = true +keyring = { workspace = true, optional = true } octocrab = "0.54" reqwest.workspace = true serde.workspace = true diff --git a/crates/github/src/auth.rs b/crates/github/src/auth.rs index 4b4bd16..8fa24e9 100644 --- a/crates/github/src/auth.rs +++ b/crates/github/src/auth.rs @@ -1,6 +1,8 @@ use thiserror::Error; -use crate::credentials::{CredentialStore, CredentialStoreError, OsCredentialStore}; +use crate::credentials::CredentialStoreError; +#[cfg(feature = "os-credentials")] +use crate::credentials::{CredentialStore, OsCredentialStore}; #[derive(Debug, Error)] pub enum AuthError { @@ -10,7 +12,10 @@ pub enum AuthError { pub fn resolve_token() -> Result { let env = std::env::var("GITHUB_TOKEN").ok(); - let store = OsCredentialStore::default(); + #[cfg(feature = "os-credentials")] + let stored_token = || OsCredentialStore::default().load(); + #[cfg(not(feature = "os-credentials"))] + let stored_token = || Ok(None); resolve_with( env, || { @@ -21,7 +26,8 @@ pub fn resolve_token() -> Result { .filter(|output| output.status.success()) .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) }, - || store.load(), + stored_token, + cfg!(feature = "os-credentials"), ) } @@ -29,6 +35,7 @@ fn resolve_with( env: Option, gh_token: impl FnOnce() -> Option, stored_token: impl FnOnce() -> Result, CredentialStoreError>, + credential_store_enabled: bool, ) -> Result { if let Some(token) = env.filter(|token| !token.trim().is_empty()) { return Ok(token); @@ -40,6 +47,11 @@ fn resolve_with( { return Ok(token); } + if !credential_store_enabled { + return Err(AuthError::NotFound( + "neither GITHUB_TOKEN nor `gh auth token` yielded one".into(), + )); + } match stored_token() { Ok(Some(token)) if !token.trim().is_empty() => return Ok(token), Ok(_) => {} @@ -67,6 +79,7 @@ mod tests { Some("tok_env".into()), || Some("tok_gh".into()), || Ok(Some("tok_store".into())), + true, ) .unwrap(), "tok_env" @@ -84,6 +97,7 @@ mod tests { Some("tok_gh".into()) }, || panic!("credential store must not be read"), + true, ) .unwrap(); @@ -98,6 +112,7 @@ mod tests { None, || Some("tok_gh\n".into()), || Ok(Some("tok_store".into())), + true, ) .unwrap(), "tok_gh" @@ -107,17 +122,32 @@ mod tests { #[test] fn falls_back_to_the_operating_system_store_after_github_cli() { assert_eq!( - resolve_with(None, || None, || Ok(Some("tok_store".into()))).unwrap(), + resolve_with(None, || None, || Ok(Some("tok_store".into())), true).unwrap(), "tok_store" ); } #[test] fn empty_everything_is_a_helpful_error() { - let err = resolve_with(Some("".into()), || None, || Ok(None)).unwrap_err(); + let err = resolve_with(Some("".into()), || None, || Ok(None), true).unwrap_err(); let message = err.to_string(); assert!(message.contains("gh auth login")); assert!(message.contains("GITHUB_TOKEN")); assert!(message.contains("operating-system credential store")); } + + #[test] + fn headless_auth_does_not_read_or_advertise_an_os_store() { + let err = resolve_with( + None, + || None, + || panic!("credential store must not be read"), + false, + ) + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("GITHUB_TOKEN")); + assert!(message.contains("gh auth login")); + assert!(!message.contains("operating-system credential store")); + } } diff --git a/crates/github/src/credentials.rs b/crates/github/src/credentials.rs index 47b952a..ca5c008 100644 --- a/crates/github/src/credentials.rs +++ b/crates/github/src/credentials.rs @@ -5,6 +5,7 @@ pub const DEFAULT_ACCOUNT: &str = "default"; #[derive(Debug, thiserror::Error)] pub enum CredentialStoreError { + #[cfg(feature = "os-credentials")] #[error("operating-system credential store failed: {0}")] Keyring(#[from] keyring::Error), #[error("operating-system credential store failed: {0}")] @@ -16,12 +17,14 @@ pub trait CredentialStore: Send + Sync { fn store(&self, credential: &str) -> Result<(), CredentialStoreError>; } +#[cfg(feature = "os-credentials")] #[derive(Debug, Clone)] pub struct OsCredentialStore { service: &'static str, account: &'static str, } +#[cfg(feature = "os-credentials")] impl Default for OsCredentialStore { fn default() -> Self { Self { @@ -31,6 +34,7 @@ impl Default for OsCredentialStore { } } +#[cfg(feature = "os-credentials")] impl OsCredentialStore { pub fn service(&self) -> &str { self.service @@ -45,6 +49,7 @@ impl OsCredentialStore { } } +#[cfg(feature = "os-credentials")] impl CredentialStore for OsCredentialStore { fn load(&self) -> Result, CredentialStoreError> { match self.entry()?.get_password() { diff --git a/crates/github/src/history.rs b/crates/github/src/history.rs new file mode 100644 index 0000000..78f8f3e --- /dev/null +++ b/crates/github/src/history.rs @@ -0,0 +1,265 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use stellr_core::{ + HistoryEvent, HistoryEventKind, HistoryPage, HistoryPageRequest, MilestoneRef, ProviderError, + RepoRef, +}; + +use crate::sync::GithubGraphqlClient; + +const FETCH_HISTORY_PAGE_QUERY: &str = r#" +query FetchIssueHistory( + $owner: String! + $name: String! + $number: Int! + $cursor: String +) { + repository(owner: $owner, name: $name) { + id + issue(number: $number) { + id + timelineItems( + first: 100 + after: $cursor + itemTypes: [CLOSED_EVENT, REOPENED_EVENT, DEMILESTONED_EVENT, MILESTONED_EVENT] + ) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + __typename + ... on ClosedEvent { id createdAt } + ... on ReopenedEvent { id createdAt } + ... on DemilestonedEvent { id createdAt milestoneTitle } + ... on MilestonedEvent { id createdAt milestoneTitle } + } + } + } + } + } +} +"#; + +pub(crate) async fn fetch_history_page( + client: &GithubGraphqlClient, + repo: &RepoRef, + request: &HistoryPageRequest, +) -> Result { + let body = GraphqlRequest { + query: FETCH_HISTORY_PAGE_QUERY, + variables: Variables { + owner: &repo.owner, + name: &repo.name, + number: request.issue_number, + cursor: request.cursor.as_deref(), + }, + }; + let response = client.post_value(&body).await?; + let envelope: GraphqlEnvelope = serde_json::from_value(response) + .map_err(|error| contextual_parse(request, "decoding history page", error))?; + let repository = envelope + .data + .and_then(|data| data.repository) + .ok_or_else(|| { + contextual_message(request, "decoding history page", "missing repository") + })?; + let issue = repository.issue.ok_or_else(|| { + contextual_message(request, "decoding history page", "missing repository issue") + })?; + if issue.id != request.issue_id { + return Err(contextual_message( + request, + "validating history issue", + "provider issue identity did not match the snapshot", + )); + } + + let mut events = Vec::new(); + let mut accepted_cursor = request.cursor.clone(); + let mut reached_cutoff = false; + for edge in issue.timeline_items.edges { + let node = edge.node; + let kind = match node.typename.as_str() { + "ClosedEvent" => HistoryEventKind::IssueClosed, + "ReopenedEvent" => HistoryEventKind::IssueReopened, + "DemilestonedEvent" => HistoryEventKind::MilestoneChanged { + from: Some(historical_milestone(request, &node)?), + to: None, + }, + "MilestonedEvent" => HistoryEventKind::MilestoneChanged { + from: None, + to: Some(historical_milestone(request, &node)?), + }, + _ => { + accepted_cursor = Some(edge.cursor); + continue; + } + }; + let provider_event_id = node.id.ok_or_else(|| { + contextual_message( + request, + "normalizing lifecycle event", + "tracked event is missing its provider identity", + ) + })?; + let occurred_at = node + .created_at + .as_deref() + .ok_or_else(|| { + contextual_message( + request, + "normalizing lifecycle event", + "tracked event is missing its timestamp", + ) + }) + .and_then(|timestamp| { + DateTime::parse_from_rfc3339(timestamp) + .map(|timestamp| timestamp.timestamp()) + .map_err(|error| { + contextual_parse(request, "normalizing lifecycle event", error) + }) + })?; + if occurred_at > request.cutoff { + reached_cutoff = true; + break; + } + events.push(HistoryEvent { + sequence: 0, + repository_id: repository.id.clone(), + issue_id: issue.id.clone(), + issue_number: request.issue_number, + provider_event_id, + occurred_at, + kind, + }); + accepted_cursor = Some(edge.cursor); + } + events.sort(); + + let page_info = issue.timeline_items.page_info; + let resume_cursor = if reached_cutoff { + accepted_cursor + } else { + page_info + .end_cursor + .clone() + .or_else(|| request.cursor.clone()) + }; + let next_cursor = if page_info.has_next_page && !reached_cutoff { + Some(page_info.end_cursor.clone().ok_or_else(|| { + contextual_message( + request, + "advancing history page", + "missing end cursor for next page", + ) + })?) + } else { + None + }; + Ok(HistoryPage { + events, + next_cursor, + resume_cursor, + complete: reached_cutoff || !page_info.has_next_page, + }) +} + +fn historical_milestone( + request: &HistoryPageRequest, + node: &TimelineNode, +) -> Result { + let title = node.milestone_title.clone().ok_or_else(|| { + contextual_message( + request, + "normalizing milestone event", + "tracked event is missing its milestone title", + ) + })?; + Ok(MilestoneRef { id: None, title }) +} + +fn contextual_parse( + request: &HistoryPageRequest, + stage: &str, + error: impl std::fmt::Display, +) -> ProviderError { + contextual_message(request, stage, &error.to_string()) +} + +fn contextual_message(request: &HistoryPageRequest, stage: &str, message: &str) -> ProviderError { + ProviderError::Parse(format!( + "issue #{} at cursor {} while {stage}: {message}", + request.issue_number, + request.cursor.as_deref().unwrap_or("") + )) +} + +#[derive(Serialize)] +struct GraphqlRequest<'a> { + query: &'static str, + variables: Variables<'a>, +} + +#[derive(Serialize)] +struct Variables<'a> { + owner: &'a str, + name: &'a str, + number: u64, + cursor: Option<&'a str>, +} + +#[derive(Deserialize)] +struct GraphqlEnvelope { + data: Option, +} + +#[derive(Deserialize)] +struct GraphqlData { + repository: Option, +} + +#[derive(Deserialize)] +struct Repository { + id: String, + issue: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Issue { + id: String, + timeline_items: TimelineConnection, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TimelineConnection { + page_info: PageInfo, + edges: Vec, +} + +#[derive(Deserialize)] +struct TimelineEdge { + cursor: String, + node: TimelineNode, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageInfo { + has_next_page: bool, + end_cursor: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TimelineNode { + #[serde(rename = "__typename")] + typename: String, + id: Option, + created_at: Option, + milestone_title: Option, +} diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 19b91ed..97c4745 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -4,5 +4,6 @@ pub mod auth; pub mod cache; pub mod credentials; pub mod device_flow; +mod history; pub mod sync; pub mod textref; diff --git a/crates/github/src/sync.rs b/crates/github/src/sync.rs index bd5e699..79ee419 100644 --- a/crates/github/src/sync.rs +++ b/crates/github/src/sync.rs @@ -1,7 +1,12 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; use octocrab::{FromResponse, Octocrab}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use stellr_core::{IssueState, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef}; +use stellr_core::{ + IssueState, IssueSyncMetadata, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef, +}; use crate::textref; @@ -11,13 +16,17 @@ const FETCH_ISSUES_QUERY: &str = r#" query FetchIssues($owner: String!, $name: String!, $cursor: String) { viewer { login } repository(owner: $owner, name: $name) { + id issues(first: 100, after: $cursor, states: [OPEN, CLOSED]) { pageInfo { hasNextPage endCursor } nodes { + id number + createdAt + updatedAt title body url @@ -69,6 +78,13 @@ impl GithubGraphqlClient { &self, request: &T, ) -> Result { + Ok(self.post_value_with_timestamp(request).await?.value) + } + + async fn post_value_with_timestamp( + &self, + request: &T, + ) -> Result { let response = self .client ._post("/graphql", Some(request)) @@ -78,17 +94,28 @@ impl GithubGraphqlClient { return Err(ProviderError::Auth("token rejected".into())); } let status = response.status().as_u16(); + let server_timestamp = response + .headers() + .get("date") + .and_then(|value| value.to_str().ok()) + .and_then(|value| DateTime::parse_from_rfc2822(value).ok()) + .map(|value| value.timestamp()); let rate_limit_exhausted = response .headers() .get("x-ratelimit-remaining") .and_then(|value| value.to_str().ok()) == Some("0"); - if matches!(status, 403 | 429) && rate_limit_exhausted { + let retry_after = response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()); + if status == 429 || (status == 403 && (rate_limit_exhausted || retry_after.is_some())) { let reset_epoch = response .headers() .get("x-ratelimit-reset") .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse().ok()); + .and_then(|value| value.parse().ok()) + .or_else(|| retry_after.and_then(|value| retry_epoch(value, server_timestamp))); return Err(ProviderError::RateLimited { reset_epoch }); } let response = octocrab::map_github_error(response) @@ -112,10 +139,34 @@ impl GithubGraphqlClient { return Err(ProviderError::Parse(messages[0].to_owned())); } } - Ok(value) + Ok(GraphqlResponse { + value, + server_timestamp, + }) } } +struct GraphqlResponse { + value: Value, + server_timestamp: Option, +} + +fn retry_epoch(value: &str, server_timestamp: Option) -> Option { + value + .parse::() + .ok() + .map(|seconds| { + server_timestamp + .unwrap_or_else(|| Utc::now().timestamp()) + .saturating_add(seconds.max(0)) + }) + .or_else(|| { + DateTime::parse_from_rfc2822(value) + .ok() + .map(|value| value.timestamp()) + }) +} + pub struct GithubProvider { client: GithubGraphqlClient, } @@ -137,9 +188,29 @@ impl GithubProvider { #[async_trait::async_trait] impl Provider for GithubProvider { async fn fetch(&self, repo: &RepoRef) -> Result { + self.fetch_all(repo).await + } + + async fn fetch_snapshot(&self, repo: &RepoRef) -> Result { + self.fetch_all(repo).await + } + + async fn fetch_history_page( + &self, + repo: &RepoRef, + request: &stellr_core::HistoryPageRequest, + ) -> Result { + crate::history::fetch_history_page(&self.client, repo, request).await + } +} + +impl GithubProvider { + async fn fetch_all(&self, repo: &RepoRef) -> Result { let mut cursor = None; let mut nodes = Vec::new(); let mut viewer_login: Option = None; + let mut repository_id: Option = None; + let mut history_cutoff = None; loop { let request = GraphqlRequest { @@ -151,9 +222,12 @@ impl Provider for GithubProvider { }, }; - let response = self.client.post_value(&request).await?; + let response = self.client.post_value_with_timestamp(&request).await?; + if history_cutoff.is_none() { + history_cutoff = response.server_timestamp; + } - let response: GraphqlEnvelope = serde_json::from_value(response) + let response: GraphqlEnvelope = serde_json::from_value(response.value) .map_err(|error| ProviderError::Parse(error.to_string()))?; let data = response @@ -167,7 +241,6 @@ impl Provider for GithubProvider { 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( @@ -178,10 +251,19 @@ impl Provider for GithubProvider { None => viewer_login = Some(data.viewer.login.clone()), } - let connection = data + let repository = data .repository - .map(|repository| repository.issues) .ok_or_else(|| ProviderError::Parse("missing data.repository.issues".into()))?; + if repository_id + .as_ref() + .is_some_and(|known| known != &repository.id) + { + return Err(ProviderError::Parse( + "repository identity changed during issue pagination".into(), + )); + } + repository_id = Some(repository.id); + let connection = repository.issues; nodes.extend(connection.nodes); if !connection.page_info.has_next_page { @@ -192,9 +274,12 @@ impl Provider for GithubProvider { })?); } - let mut issues = map_issues(nodes); - issues.sort_by_key(|issue| issue.number); - Ok(ProviderSnapshot::new(viewer_login, issues)) + Ok(map_snapshot( + viewer_login, + repository_id, + history_cutoff, + nodes, + )) } } @@ -205,53 +290,93 @@ fn map_octocrab_error(error: octocrab::Error) -> ProviderError { } } -fn map_issues(nodes: Vec) -> Vec { - let mut issues = nodes - .into_iter() - .map(|node| { - let body = node.body.unwrap_or_default(); - let blocked_by = node - .blocked_by +fn map_snapshot( + viewer_login: Option, + repository_id: Option, + history_cutoff: Option, + nodes: Vec, +) -> ProviderSnapshot { + let mut issues = Vec::with_capacity(nodes.len()); + let mut history = Vec::with_capacity(nodes.len()); + let mut inversions = Vec::new(); + + for node in nodes { + let body = node.body.unwrap_or_default(); + let refs = textref::scan(&body); + let mut blocked_by = node + .blocked_by + .nodes + .into_iter() + .map(|issue| issue.number) + .chain(refs.blocked_by) + .collect::>(); + blocked_by.sort_unstable(); + blocked_by.dedup(); + + inversions.extend(refs.blocks.into_iter().map(|target| (node.number, target))); + let parent_issue = node.parent.map(|parent| parent.number).or_else(|| { + if refs.parents.len() == 1 { + Some(refs.parents[0]) + } else { + None + } + }); + history.push(IssueSyncMetadata { + issue_id: node.id.clone(), + number: node.number, + created_at: node.created_at.timestamp(), + updated_at: node.updated_at.timestamp(), + // Present-day milestone membership cannot establish membership at creation. + milestone: None, + }); + issues.push(RawIssue { + number: node.number, + parent_issue, + title: node.title, + body, + state: match node.state { + GithubIssueState::Open => IssueState::Open, + GithubIssueState::Closed if node.state_reason.as_deref() == Some("NOT_PLANNED") => { + IssueState::ClosedNotPlanned + } + GithubIssueState::Closed => IssueState::Closed, + }, + assignees: node + .assignees .nodes .into_iter() - .map(|issue| issue.number) - .collect::>(); - RawIssue { - number: node.number, - parent_issue: node.parent.map(|parent| parent.number), - title: node.title, - body, - state: match node.state { - GithubIssueState::Open => IssueState::Open, - GithubIssueState::Closed - if node.state_reason.as_deref() == Some("NOT_PLANNED") => - { - IssueState::ClosedNotPlanned - } - GithubIssueState::Closed => IssueState::Closed, - }, - assignees: node - .assignees - .nodes - .into_iter() - .map(|assignee| assignee.login) - .collect(), - milestone: node.milestone.map(|milestone| milestone.title), - labels: node - .labels - .nodes - .into_iter() - .map(|label| label.name) - .collect(), - blocked_by, - url: node.url, - } - }) - .collect::>(); + .map(|assignee| assignee.login) + .collect(), + milestone: node.milestone.map(|milestone| milestone.title), + labels: node + .labels + .nodes + .into_iter() + .map(|label| label.name) + .collect(), + blocked_by, + url: node.url, + }); + } - textref::enrich_relationships(&mut issues); + let positions = issues + .iter() + .enumerate() + .map(|(index, issue)| (issue.number, index)) + .collect::>(); + for (blocker, target) in inversions { + if let Some(&index) = positions.get(&target) { + issues[index].blocked_by.push(blocker); + } + } + for issue in &mut issues { + issue.blocked_by.sort_unstable(); + issue.blocked_by.dedup(); + } + issues.sort_by_key(|issue| issue.number); + history.sort_by_key(|issue| issue.number); - issues + ProviderSnapshot::with_history(viewer_login, repository_id, history_cutoff, issues, history) } #[derive(Serialize)] @@ -285,6 +410,7 @@ struct Viewer { #[derive(Deserialize)] struct Repository { + id: String, issues: IssueConnection, } @@ -305,7 +431,10 @@ struct PageInfo { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct IssueNode { + id: String, number: u64, + created_at: DateTime, + updated_at: DateTime, title: String, body: Option, url: String, @@ -335,7 +464,7 @@ struct Assignee { login: String, } -#[derive(Deserialize)] +#[derive(Clone, Deserialize)] struct Milestone { title: String, } diff --git a/crates/github/tests/credential_store_test.rs b/crates/github/tests/credential_store_test.rs index a928d23..031ae62 100644 --- a/crates/github/tests/credential_store_test.rs +++ b/crates/github/tests/credential_store_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "os-credentials")] + use stellr_github::credentials::{DEFAULT_ACCOUNT, OsCredentialStore, SERVICE_NAME}; #[test] diff --git a/crates/github/tests/history_test.rs b/crates/github/tests/history_test.rs new file mode 100644 index 0000000..0b8084f --- /dev/null +++ b/crates/github/tests/history_test.rs @@ -0,0 +1,255 @@ +use serde_json::{Value, json}; +use stellr_core::{ + HistoryEventKind, HistoryPageRequest, MilestoneRef, Provider, ProviderError, RepoRef, +}; +use stellr_github::sync::GithubProvider; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn repo() -> RepoRef { + RepoRef { + owner: "o".into(), + name: "r".into(), + } +} + +fn request(cursor: Option<&str>) -> HistoryPageRequest { + HistoryPageRequest { + issue_id: "I_78".into(), + issue_number: 78, + cursor: cursor.map(str::to_owned), + cutoff: 1_785_850_000, + } +} + +#[tokio::test] +async fn fetches_one_targeted_history_page_and_normalizes_tracked_events() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "repository": { + "id": "R_repo", + "issue": { + "id": "I_78", + "timelineItems": { + "pageInfo": { "hasNextPage": true, "endCursor": "CUR2" }, + "edges": [ + { "cursor": "EDGE1", "node": { + "__typename": "ReopenedEvent", + "id": "E_reopen", + "createdAt": "2026-08-04T13:10:00Z" + }}, + { "cursor": "EDGE2", "node": { + "__typename": "AssignedEvent", + "id": "E_ignore", + "createdAt": "2026-08-04T13:05:00Z" + }}, + { "cursor": "EDGE3", "node": { + "__typename": "ClosedEvent", + "id": "E_close", + "createdAt": "2026-08-04T13:00:00Z" + }}, + { "cursor": "EDGE4", "node": { + "__typename": "DemilestonedEvent", + "id": "E_demilestone", + "createdAt": "2026-08-04T13:15:00Z", + "milestoneTitle": "Alpha" + }}, + { "cursor": "EDGE5", "node": { + "__typename": "MilestonedEvent", + "id": "E_milestone", + "createdAt": "2026-08-04T13:15:00Z", + "milestoneTitle": "Beta" + }}, + { "cursor": "EDGE6", "node": { + "__typename": "ClosedEvent", + "id": "E_after_cutoff", + "createdAt": "2026-08-04T14:00:00Z" + }} + ] + } + } + } + } + }))) + .expect(1) + .mount(&server) + .await; + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + + let page = provider + .fetch_history_page(&repo(), &request(Some("CUR1"))) + .await + .unwrap(); + let received = server.received_requests().await.unwrap(); + let body: Value = serde_json::from_slice(&received[0].body).unwrap(); + let query = body["query"].as_str().unwrap(); + + assert!(query.contains("issue(number: $number)")); + assert!(query.contains( + "itemTypes: [CLOSED_EVENT, REOPENED_EVENT, DEMILESTONED_EVENT, MILESTONED_EVENT]" + )); + assert_eq!(body["variables"]["owner"], "o"); + assert_eq!(body["variables"]["name"], "r"); + assert_eq!(body["variables"]["number"], 78); + assert_eq!(body["variables"]["cursor"], "CUR1"); + assert_eq!(page.next_cursor, None); + assert_eq!(page.resume_cursor.as_deref(), Some("EDGE5")); + assert!(page.complete); + assert_eq!(page.events.len(), 4); + assert_eq!(page.events[0].provider_event_id, "E_close"); + assert!(matches!(page.events[0].kind, HistoryEventKind::IssueClosed)); + assert_eq!(page.events[1].provider_event_id, "E_reopen"); + assert!(matches!( + page.events[1].kind, + HistoryEventKind::IssueReopened + )); + assert_eq!(page.events[2].provider_event_id, "E_demilestone"); + assert_eq!( + page.events[2].kind, + HistoryEventKind::MilestoneChanged { + from: Some(MilestoneRef { + id: None, + title: "Alpha".into(), + }), + to: None, + } + ); + assert_eq!(page.events[3].provider_event_id, "E_milestone"); + assert_eq!( + page.events[3].kind, + HistoryEventKind::MilestoneChanged { + from: None, + to: Some(MilestoneRef { + id: None, + title: "Beta".into(), + }), + } + ); +} + +#[tokio::test] +async fn preserves_the_terminal_cursor_for_delta_only_resume() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "repository": { + "id": "R_repo", + "issue": { + "id": "I_78", + "timelineItems": { + "pageInfo": { "hasNextPage": false, "endCursor": "CUR_END" }, + "edges": [] + } + } + } + } + }))) + .mount(&server) + .await; + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + + let page = provider + .fetch_history_page(&repo(), &request(Some("CUR1"))) + .await + .unwrap(); + + assert!(page.complete); + assert_eq!(page.next_cursor, None); + assert_eq!(page.resume_cursor.as_deref(), Some("CUR_END")); +} + +#[tokio::test] +async fn malformed_tracked_event_reports_issue_cursor_and_stage() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "repository": { + "id": "R_repo", + "issue": { + "id": "I_78", + "timelineItems": { + "pageInfo": { "hasNextPage": false, "endCursor": null }, + "edges": [{ + "cursor": "EDGE1", + "node": { + "__typename": "ClosedEvent", + "id": null, + "createdAt": "2026-08-04T13:00:00Z" + } + }] + } + } + } + } + }))) + .mount(&server) + .await; + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + + let error = provider + .fetch_history_page(&repo(), &request(Some("CUR1"))) + .await + .unwrap_err(); + + match error { + ProviderError::Parse(message) => { + assert!(message.contains("issue #78")); + assert!(message.contains("CUR1")); + assert!(message.contains("normalizing lifecycle event")); + } + other => panic!("expected parse error, got {other:?}"), + } +} + +#[tokio::test] +async fn malformed_milestone_event_reports_issue_cursor_and_stage() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "repository": { + "id": "R_repo", + "issue": { + "id": "I_78", + "timelineItems": { + "pageInfo": { "hasNextPage": false, "endCursor": null }, + "edges": [{ + "cursor": "EDGE1", + "node": { + "__typename": "MilestonedEvent", + "id": "E_milestone", + "createdAt": "2026-08-04T13:00:00Z", + "milestoneTitle": null + } + }] + } + } + } + } + }))) + .mount(&server) + .await; + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + + let error = provider + .fetch_history_page(&repo(), &request(Some("CUR1"))) + .await + .unwrap_err(); + + match error { + ProviderError::Parse(message) => { + assert!(message.contains("issue #78")); + assert!(message.contains("CUR1")); + assert!(message.contains("normalizing milestone event")); + } + other => panic!("expected parse error, got {other:?}"), + } +} diff --git a/crates/github/tests/sync_test.rs b/crates/github/tests/sync_test.rs index e88d604..c3cb418 100644 --- a/crates/github/tests/sync_test.rs +++ b/crates/github/tests/sync_test.rs @@ -31,6 +31,7 @@ fn page_for_viewer( "login": viewer_login }, "repository": { + "id": "R_repo", "issues": { "pageInfo": { "hasNextPage": has_next_page, @@ -171,6 +172,70 @@ async fn fetch_rejects_a_viewer_change_during_pagination() { assert!(error.to_string().contains("viewer login changed")); } +#[tokio::test] +async fn snapshot_piggybacks_stable_history_metadata_on_the_issue_query() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("date", "Tue, 04 Aug 2026 13:30:00 GMT") + .set_body_json(json!({ + "data": { + "viewer": { "login": "octocat" }, + "repository": { + "id": "R_repo", + "issues": { + "pageInfo": { "hasNextPage": false, "endCursor": null }, + "nodes": [{ + "id": "I_78", + "number": 78, + "createdAt": "2026-08-04T12:00:00Z", + "updatedAt": "2026-08-04T13:00:00Z", + "title": "History", + "body": "", + "url": "https://example.test/o/r/issues/78", + "state": "OPEN", + "stateReason": null, + "assignees": { "nodes": [] }, + "milestone": { "id": "M_1", "title": "M1" }, + "labels": { "nodes": [] }, + "blockedBy": { "nodes": [] }, + "parent": null + }] + } + } + } + })), + ) + .expect(1) + .mount(&server) + .await; + + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + let result = provider.fetch_snapshot(&repo()).await; + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1, "provider result: {result:?}"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + let query = body["query"].as_str().unwrap(); + + assert!(query.contains("repository(owner: $owner, name: $name) {\n id")); + assert!(query.contains("nodes {\n id\n number")); + assert!(query.contains("createdAt")); + assert!(query.contains("updatedAt")); + let snapshot = result.unwrap(); + assert_eq!(snapshot.viewer_login.as_deref(), Some("octocat")); + assert_eq!(snapshot.repository_id.as_deref(), Some("R_repo")); + assert_eq!(snapshot.history_cutoff, Some(1_785_850_200)); + assert_eq!(snapshot.issues.len(), 1); + assert_eq!(snapshot.history.len(), 1); + assert_eq!(snapshot.history[0].issue_id, "I_78"); + assert_eq!(snapshot.history[0].number, 78); + assert_eq!(snapshot.history[0].created_at, 1_785_844_800); + assert_eq!(snapshot.history[0].updated_at, 1_785_848_400); + assert_eq!(snapshot.history[0].milestone, None); +} + #[tokio::test] async fn fetch_maps_a_rejected_token_to_auth() { let server = MockServer::start().await; @@ -216,6 +281,31 @@ async fn fetch_maps_rate_limit_exhaustion_with_its_reset_time() { )); } +#[tokio::test] +async fn fetch_maps_secondary_rate_limit_retry_after_without_budget_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("date", "Tue, 04 Aug 2026 13:30:00 GMT") + .insert_header("retry-after", "120") + .set_body_json(json!({ "message": "slow down" })), + ) + .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::RateLimited { + reset_epoch: Some(1_785_850_320) + } + )); +} + #[tokio::test] async fn fetch_maps_http_429_rate_limit_exhaustion() { let server = MockServer::start().await; @@ -256,7 +346,10 @@ fn node( parent: Option, ) -> Value { json!({ + "id": format!("I_{number}"), "number": number, + "createdAt": "2026-08-04T12:00:00Z", + "updatedAt": "2026-08-04T13:00:00Z", "title": title, "body": body, "url": url, @@ -268,7 +361,7 @@ fn node( .map(|login| json!({ "login": login })) .collect::>() }, - "milestone": milestone.map(|title| json!({ "title": title })), + "milestone": milestone.map(|title| json!({ "id": format!("M_{title}"), "title": title })), "labels": { "nodes": labels .iter() diff --git a/crates/history/Cargo.toml b/crates/history/Cargo.toml new file mode 100644 index 0000000..dae63e5 --- /dev/null +++ b/crates/history/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "stellr-history" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +rusqlite.workspace = true +serde_json.workspace = true +stellr-core = { path = "../core" } +thiserror.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/history/src/lib.rs b/crates/history/src/lib.rs new file mode 100644 index 0000000..eda4b6b --- /dev/null +++ b/crates/history/src/lib.rs @@ -0,0 +1,3 @@ +pub mod store; + +pub use store::{HistoryStore, PageCheckpoint, PendingHistoryIssue, RepositorySeed, StoreError}; diff --git a/crates/history/src/store.rs b/crates/history/src/store.rs new file mode 100644 index 0000000..28172a8 --- /dev/null +++ b/crates/history/src/store.rs @@ -0,0 +1,832 @@ +use std::{ + path::Path, + sync::{Arc, Mutex, MutexGuard}, +}; + +use rusqlite::{Connection, OptionalExtension, params}; +use stellr_core::{ + HistoryEvent, HistoryEventKind, HistoryImportState, HistorySummary, IssueSyncMetadata, +}; + +const SCHEMA_VERSION: i64 = 3; + +#[derive(Clone, Copy)] +enum StoredImportState { + Unavailable, + Building, + Complete, + Delayed, + RateLimited, + Failed, +} + +impl StoredImportState { + const fn as_str(self) -> &'static str { + match self { + Self::Unavailable => "unavailable", + Self::Building => "building", + Self::Complete => "complete", + Self::Delayed => "delayed", + Self::RateLimited => "rate_limited", + Self::Failed => "failed", + } + } + + fn parse(value: &str) -> Result { + match value { + "unavailable" => Ok(Self::Unavailable), + "building" => Ok(Self::Building), + "complete" => Ok(Self::Complete), + "delayed" => Ok(Self::Delayed), + "rate_limited" => Ok(Self::RateLimited), + "failed" => Ok(Self::Failed), + _ => Err(StoreError::Invalid(format!( + "unknown history import state: {value}" + ))), + } + } +} + +impl From for HistoryImportState { + fn from(value: StoredImportState) -> Self { + match value { + StoredImportState::Unavailable => Self::Unavailable, + StoredImportState::Building => Self::Building, + StoredImportState::Complete => Self::Complete, + StoredImportState::Delayed => Self::Delayed, + StoredImportState::RateLimited => Self::RateLimited, + StoredImportState::Failed => Self::Failed, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepositorySeed { + pub space_id: String, + pub provider_repository_id: String, + pub verified_through: i64, + pub timeline_required: bool, + pub issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingHistoryIssue { + pub issue_id: String, + pub issue_number: u64, + pub cursor: Option, + pub cutoff: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageCheckpoint { + pub space_id: String, + pub issue_id: String, + pub events: Vec, + pub next_cursor: Option, + pub resume_cursor: Option, + pub complete: bool, +} + +#[derive(Clone)] +pub struct HistoryStore { + connection: Arc>, +} + +impl HistoryStore { + pub fn open_in_memory() -> Result { + let mut connection = Connection::open_in_memory()?; + connection.pragma_update(None, "foreign_keys", true)?; + migrate(&mut connection)?; + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + }) + } + + pub fn open(path: impl AsRef) -> Result { + if let Some(parent) = path.as_ref().parent() { + std::fs::create_dir_all(parent)?; + } + let mut connection = Connection::open(path)?; + connection.pragma_update(None, "foreign_keys", true)?; + migrate(&mut connection)?; + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + }) + } + + pub fn initialize_repository( + &self, + seed: &RepositorySeed, + ) -> Result { + validate_seed(seed)?; + let mut issues = seed.issues.clone(); + issues.sort_by(|left, right| { + (left.created_at, HistoryEvent::creation_id(&left.issue_id)) + .cmp(&(right.created_at, HistoryEvent::creation_id(&right.issue_id))) + }); + let timeline_required = seed.timeline_required && !issues.is_empty(); + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + + transaction.execute( + "INSERT INTO repositories ( + space_id, provider_repository_id, import_state, total_issues, + completed_issues, verified_through, diagnostic, resume_at, cutoff, + catch_up_required + ) VALUES (?1, ?2, ?3, 0, 0, ?4, NULL, NULL, ?5, ?6) + ON CONFLICT(space_id) DO NOTHING", + params![ + seed.space_id, + seed.provider_repository_id, + if timeline_required { + StoredImportState::Building.as_str() + } else { + StoredImportState::Complete.as_str() + }, + (!timeline_required).then_some(seed.verified_through), + seed.verified_through, + timeline_required, + ], + )?; + let stored_repository_id: String = transaction.query_row( + "SELECT provider_repository_id FROM repositories WHERE space_id = ?1", + params![seed.space_id], + |row| row.get(0), + )?; + if stored_repository_id != seed.provider_repository_id { + return Err(StoreError::Invalid( + "provider repository identity changed for an existing space".into(), + )); + } + + let (catch_up_required, pending_count): (bool, i64) = transaction.query_row( + "SELECT r.catch_up_required, + SUM(CASE WHEN i.complete = 0 THEN 1 ELSE 0 END) + FROM repositories r + LEFT JOIN issues i ON i.space_id = r.space_id + WHERE r.space_id = ?1 + GROUP BY r.space_id", + params![seed.space_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if catch_up_required && pending_count == 0 { + transaction.execute( + "UPDATE repositories SET catch_up_required = 0 WHERE space_id = ?1", + params![seed.space_id], + )?; + } + + transaction.execute( + "UPDATE repositories + SET cutoff = ?2, diagnostic = NULL, resume_at = NULL + WHERE space_id = ?1", + params![seed.space_id, seed.verified_through], + )?; + + let mut changed = false; + for issue in issues { + let issue_number = i64::try_from(issue.number)?; + // Later pages can be observed after the response-backed cutoff. Keeping + // their baseline at the cutoff guarantees the confirming snapshot will + // queue any activity that happened while pagination was in flight. + let observed_updated_at = issue.updated_at.min(seed.verified_through); + let (milestone_id, milestone_title) = issue + .milestone + .as_ref() + .map(|milestone| (milestone.id.as_deref(), Some(milestone.title.as_str()))) + .unwrap_or((None, None)); + let stored_updated_at = transaction + .query_row( + "SELECT updated_at FROM issues + WHERE space_id = ?1 AND provider_issue_id = ?2", + params![seed.space_id, issue.issue_id], + |row| row.get::<_, i64>(0), + ) + .optional()?; + match stored_updated_at { + None => { + changed |= timeline_required; + transaction.execute( + "INSERT INTO issues ( + space_id, provider_issue_id, issue_number, created_at, updated_at, + milestone_id, milestone_title, cursor, complete + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)", + params![ + seed.space_id, + issue.issue_id, + issue_number, + issue.created_at, + observed_updated_at, + milestone_id, + milestone_title, + !timeline_required + ], + )?; + } + Some(stored_updated_at) => { + let issue_changed = + timeline_required && observed_updated_at > stored_updated_at; + changed |= issue_changed; + transaction.execute( + "UPDATE issues + SET issue_number = ?3, + created_at = ?4, + updated_at = MAX(updated_at, ?5), + milestone_id = ?6, + milestone_title = ?7, + complete = CASE WHEN ?8 THEN 0 ELSE complete END + WHERE space_id = ?1 AND provider_issue_id = ?2", + params![ + seed.space_id, + issue.issue_id, + issue_number, + issue.created_at, + observed_updated_at, + milestone_id, + milestone_title, + issue_changed + ], + )?; + } + } + + if issue.created_at <= seed.verified_through { + let kind = HistoryEventKind::IssueCreated { + milestone: issue.milestone, + }; + transaction.execute( + "INSERT OR IGNORE INTO events ( + space_id, provider_event_id, provider_issue_id, issue_number, + occurred_at, payload + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + seed.space_id, + HistoryEvent::creation_id(&issue.issue_id), + issue.issue_id, + issue_number, + issue.created_at, + serde_json::to_string(&kind)? + ], + )?; + } + } + + if changed { + transaction.execute( + "UPDATE repositories SET catch_up_required = 1 WHERE space_id = ?1", + params![seed.space_id], + )?; + } + let (completed, total, catch_up_required): (i64, i64, bool) = transaction.query_row( + "SELECT SUM(CASE WHEN i.complete = 1 THEN 1 ELSE 0 END), + COUNT(i.provider_issue_id), r.catch_up_required + FROM repositories r + LEFT JOIN issues i ON i.space_id = r.space_id + WHERE r.space_id = ?1 + GROUP BY r.space_id", + params![seed.space_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + let complete = completed == total && !catch_up_required; + transaction.execute( + "UPDATE repositories + SET import_state = ?2, + total_issues = ?3, + completed_issues = ?4, + verified_through = CASE WHEN ?5 THEN cutoff ELSE verified_through END + WHERE space_id = ?1", + params![ + seed.space_id, + if complete { + StoredImportState::Complete.as_str() + } else { + StoredImportState::Building.as_str() + }, + total, + completed, + complete + ], + )?; + + transaction.commit()?; + drop(connection); + self.summary(&seed.space_id)?.ok_or_else(|| { + StoreError::Invalid("initialized repository is missing from the ledger".into()) + }) + } + + pub fn summary(&self, space_id: &str) -> Result, StoreError> { + let connection = self.connection()?; + summary(&connection, space_id) + } + + pub fn events_after( + &self, + space_id: &str, + sequence: u64, + ) -> Result, StoreError> { + let sequence = i64::try_from(sequence)?; + let connection = self.connection()?; + let mut statement = connection.prepare( + "SELECT e.sequence, r.provider_repository_id, e.provider_issue_id, + e.issue_number, e.provider_event_id, e.occurred_at, e.payload + FROM events e + JOIN repositories r ON r.space_id = e.space_id + WHERE e.space_id = ?1 AND e.sequence > ?2 + ORDER BY e.occurred_at, e.provider_event_id", + )?; + let rows = statement.query_map(params![space_id, sequence], |row| { + let sequence: i64 = row.get(0)?; + let issue_number: i64 = row.get(3)?; + let payload: String = row.get(6)?; + Ok(( + sequence, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + issue_number, + row.get::<_, String>(4)?, + row.get::<_, i64>(5)?, + payload, + )) + })?; + let mut events = Vec::new(); + for row in rows { + let ( + sequence, + repository_id, + issue_id, + issue_number, + provider_event_id, + occurred_at, + payload, + ) = row?; + events.push(HistoryEvent { + sequence: u64::try_from(sequence)?, + repository_id, + issue_id, + issue_number: u64::try_from(issue_number)?, + provider_event_id, + occurred_at, + kind: serde_json::from_str(&payload)?, + }); + } + Ok(events) + } + + pub fn pending_issue(&self, space_id: &str) -> Result, StoreError> { + let connection = self.connection()?; + let row = connection + .query_row( + "SELECT i.provider_issue_id, i.issue_number, i.cursor, r.cutoff + FROM issues i + JOIN repositories r ON r.space_id = i.space_id + WHERE i.space_id = ?1 AND i.complete = 0 + ORDER BY i.issue_number + LIMIT 1", + params![space_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)?, + )) + }, + ) + .optional()?; + row.map(|(issue_id, issue_number, cursor, cutoff)| { + Ok(PendingHistoryIssue { + issue_id, + issue_number: u64::try_from(issue_number)?, + cursor, + cutoff, + }) + }) + .transpose() + } + + pub fn checkpoint_page( + &self, + checkpoint: &PageCheckpoint, + ) -> Result { + if checkpoint.space_id.trim().is_empty() || checkpoint.issue_id.trim().is_empty() { + return Err(StoreError::Invalid( + "checkpoint identities must not be empty".into(), + )); + } + if checkpoint.complete && checkpoint.next_cursor.is_some() { + return Err(StoreError::Invalid( + "a complete history page cannot retain a next cursor".into(), + )); + } + if !checkpoint.complete && checkpoint.next_cursor.is_none() { + return Err(StoreError::Invalid( + "an incomplete history page requires a next cursor".into(), + )); + } + + let mut events = checkpoint.events.clone(); + events.sort(); + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + let (repository_id, cutoff): (String, i64) = transaction.query_row( + "SELECT provider_repository_id, cutoff + FROM repositories WHERE space_id = ?1", + params![checkpoint.space_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let (issue_number, issue_created_at): (i64, i64) = transaction.query_row( + "SELECT issue_number, created_at FROM issues + WHERE space_id = ?1 AND provider_issue_id = ?2", + params![checkpoint.space_id, checkpoint.issue_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let issue_number = u64::try_from(issue_number)?; + + for event in events { + if event.repository_id != repository_id + || event.issue_id != checkpoint.issue_id + || event.issue_number != issue_number + { + return Err(StoreError::Invalid( + "history event does not match its repository checkpoint".into(), + )); + } + if event.occurred_at > cutoff { + return Err(StoreError::Invalid( + "history event is later than the repository cutoff".into(), + )); + } + transaction.execute( + "INSERT OR IGNORE INTO events ( + space_id, provider_event_id, provider_issue_id, issue_number, + occurred_at, payload + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + checkpoint.space_id, + event.provider_event_id, + checkpoint.issue_id, + i64::try_from(issue_number)?, + event.occurred_at, + serde_json::to_string(&event.kind)? + ], + )?; + } + reconcile_creation_milestone( + &transaction, + &checkpoint.space_id, + &checkpoint.issue_id, + issue_created_at, + )?; + + let stored_cursor = if checkpoint.complete { + checkpoint.resume_cursor.as_ref() + } else { + checkpoint.next_cursor.as_ref() + }; + transaction.execute( + "UPDATE issues + SET cursor = ?3, complete = ?4 + WHERE space_id = ?1 AND provider_issue_id = ?2", + params![ + checkpoint.space_id, + checkpoint.issue_id, + stored_cursor, + checkpoint.complete + ], + )?; + let (completed, total, catch_up_required): (i64, i64, bool) = transaction.query_row( + "SELECT SUM(CASE WHEN i.complete = 1 THEN 1 ELSE 0 END), COUNT(*), + r.catch_up_required + FROM repositories r + JOIN issues i ON i.space_id = r.space_id + WHERE r.space_id = ?1", + params![checkpoint.space_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + let repository_complete = completed == total && !catch_up_required; + transaction.execute( + "UPDATE repositories + SET import_state = ?2, + completed_issues = ?3, + verified_through = CASE WHEN ?4 IS NOT NULL THEN ?4 ELSE verified_through END, + diagnostic = NULL, + resume_at = NULL + WHERE space_id = ?1", + params![ + checkpoint.space_id, + if repository_complete { + StoredImportState::Complete.as_str() + } else { + StoredImportState::Building.as_str() + }, + completed, + repository_complete.then_some(cutoff) + ], + )?; + transaction.commit()?; + drop(connection); + self.summary(&checkpoint.space_id)?.ok_or_else(|| { + StoreError::Invalid("checkpoint repository disappeared from the ledger".into()) + }) + } + + pub fn mark_failed( + &self, + space_id: &str, + diagnostic: impl Into, + ) -> Result { + let connection = self.connection()?; + let changed = connection.execute( + "UPDATE repositories + SET import_state = ?2, diagnostic = ?3, resume_at = NULL + WHERE space_id = ?1", + params![ + space_id, + StoredImportState::Failed.as_str(), + diagnostic.into() + ], + )?; + if changed == 0 { + return Err(StoreError::Invalid( + "cannot fail unknown history repository".into(), + )); + } + drop(connection); + self.summary(space_id)?.ok_or_else(|| { + StoreError::Invalid("failed repository disappeared from the ledger".into()) + }) + } + + pub fn mark_rate_limited( + &self, + space_id: &str, + resume_at: Option, + ) -> Result { + let connection = self.connection()?; + let changed = connection.execute( + "UPDATE repositories + SET import_state = ?2, + diagnostic = 'GitHub rate limit exceeded', + resume_at = ?3 + WHERE space_id = ?1", + params![space_id, StoredImportState::RateLimited.as_str(), resume_at], + )?; + if changed == 0 { + return Err(StoreError::Invalid( + "cannot rate-limit unknown history repository".into(), + )); + } + drop(connection); + self.summary(space_id)?.ok_or_else(|| { + StoreError::Invalid("rate-limited repository disappeared from the ledger".into()) + }) + } + + pub fn retry_repository(&self, space_id: &str) -> Result { + let connection = self.connection()?; + Ok(connection.execute( + "UPDATE repositories + SET import_state = ?2, diagnostic = NULL, resume_at = NULL + WHERE space_id = ?1 + AND import_state IN (?3, ?4, ?5)", + params![ + space_id, + StoredImportState::Building.as_str(), + StoredImportState::RateLimited.as_str(), + StoredImportState::Delayed.as_str(), + StoredImportState::Failed.as_str() + ], + )? > 0) + } + + pub fn remove_repository(&self, space_id: &str) -> Result { + let connection = self.connection()?; + Ok(connection.execute( + "DELETE FROM repositories WHERE space_id = ?1", + params![space_id], + )? > 0) + } + + fn connection(&self) -> Result, StoreError> { + self.connection + .lock() + .map_err(|_| StoreError::Invalid("history connection lock is poisoned".into())) + } +} + +fn reconcile_creation_milestone( + transaction: &rusqlite::Transaction<'_>, + space_id: &str, + issue_id: &str, + issue_created_at: i64, +) -> Result<(), StoreError> { + let mut statement = transaction.prepare( + "SELECT occurred_at, payload + FROM events + WHERE space_id = ?1 AND provider_issue_id = ?2 + ORDER BY occurred_at, provider_event_id", + )?; + let rows = statement.query_map(params![space_id, issue_id], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })?; + let mut initial_milestone = None; + let mut saw_milestone_transition = false; + for row in rows { + let (occurred_at, payload) = row?; + let kind: HistoryEventKind = serde_json::from_str(&payload)?; + let HistoryEventKind::MilestoneChanged { from, to } = kind else { + continue; + }; + initial_milestone = if from.is_some() { + from + } else if occurred_at == issue_created_at { + to + } else { + None + }; + saw_milestone_transition = true; + break; + } + drop(statement); + if saw_milestone_transition { + let payload = serde_json::to_string(&HistoryEventKind::IssueCreated { + milestone: initial_milestone, + })?; + transaction.execute( + "UPDATE events + SET sequence = (SELECT COALESCE(MAX(sequence), 0) + 1 FROM events), + payload = ?3 + WHERE space_id = ?1 AND provider_event_id = ?2 AND payload <> ?3", + params![space_id, HistoryEvent::creation_id(issue_id), payload], + )?; + } + Ok(()) +} + +fn validate_seed(seed: &RepositorySeed) -> Result<(), StoreError> { + if seed.space_id.trim().is_empty() || seed.provider_repository_id.trim().is_empty() { + return Err(StoreError::Invalid( + "repository identities must not be empty".into(), + )); + } + for issue in &seed.issues { + if issue.issue_id.trim().is_empty() { + return Err(StoreError::Invalid( + "provider issue identity must not be empty".into(), + )); + } + let _ = i64::try_from(issue.number)?; + } + Ok(()) +} + +fn migrate(connection: &mut Connection) -> Result<(), StoreError> { + let mut version = + connection.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?; + if version > SCHEMA_VERSION { + return Err(StoreError::UnsupportedSchema(version)); + } + if version == 0 { + let transaction = connection.transaction()?; + transaction.execute_batch( + "CREATE TABLE repositories ( + space_id TEXT PRIMARY KEY, + provider_repository_id TEXT NOT NULL, + import_state TEXT NOT NULL, + total_issues INTEGER NOT NULL, + completed_issues INTEGER NOT NULL, + verified_through INTEGER, + diagnostic TEXT, + resume_at INTEGER + ); + CREATE TABLE issues ( + space_id TEXT NOT NULL REFERENCES repositories(space_id) ON DELETE CASCADE, + provider_issue_id TEXT NOT NULL, + issue_number INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + milestone_id TEXT, + milestone_title TEXT, + cursor TEXT, + complete INTEGER NOT NULL, + PRIMARY KEY (space_id, provider_issue_id) + ); + CREATE TABLE events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + space_id TEXT NOT NULL REFERENCES repositories(space_id) ON DELETE CASCADE, + provider_event_id TEXT NOT NULL, + provider_issue_id TEXT NOT NULL, + issue_number INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE (space_id, provider_event_id) + ); + CREATE INDEX events_space_time + ON events(space_id, occurred_at, provider_event_id);", + )?; + transaction.pragma_update(None, "user_version", 1)?; + transaction.commit()?; + version = 1; + } + if version == 1 { + let transaction = connection.transaction()?; + transaction.execute_batch("ALTER TABLE repositories ADD COLUMN cutoff INTEGER;")?; + transaction.execute_batch( + "UPDATE repositories SET cutoff = verified_through WHERE cutoff IS NULL; + UPDATE issues SET complete = 0; + UPDATE repositories + SET import_state = CASE WHEN total_issues = 0 THEN 'complete' ELSE 'building' END, + completed_issues = 0, + verified_through = CASE WHEN total_issues = 0 THEN cutoff ELSE NULL END, + diagnostic = NULL, + resume_at = NULL;", + )?; + transaction.pragma_update(None, "user_version", 2)?; + transaction.commit()?; + version = 2; + } + if version == 2 { + let transaction = connection.transaction()?; + transaction.execute_batch( + "ALTER TABLE repositories + ADD COLUMN catch_up_required INTEGER NOT NULL DEFAULT 0; + UPDATE repositories + SET catch_up_required = CASE WHEN total_issues > 0 THEN 1 ELSE 0 END, + import_state = CASE WHEN total_issues > 0 THEN 'building' ELSE import_state END;", + )?; + transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; + transaction.commit()?; + } + Ok(()) +} + +fn summary(connection: &Connection, space_id: &str) -> Result, StoreError> { + let row = connection + .query_row( + "SELECT r.import_state, r.completed_issues, r.total_issues, + MIN(e.occurred_at), r.verified_through, MAX(e.sequence), + r.diagnostic, r.resume_at + FROM repositories r + LEFT JOIN events e ON e.space_id = r.space_id + WHERE r.space_id = ?1 + GROUP BY r.space_id", + params![space_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + )) + }, + ) + .optional()?; + let Some(( + state, + completed_issues, + total_issues, + earliest_event_at, + verified_through, + revision, + diagnostic, + resume_at, + )) = row + else { + return Ok(None); + }; + Ok(Some(HistorySummary { + state: parse_state(&state)?, + completed_issues: u64::try_from(completed_issues)?, + total_issues: u64::try_from(total_issues)?, + earliest_event_at, + verified_through, + revision: u64::try_from(revision.unwrap_or(0))?, + diagnostic, + resume_at, + })) +} + +fn parse_state(state: &str) -> Result { + StoredImportState::parse(state).map(Into::into) +} + +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("history database I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("history database failed: {0}")] + Sql(#[from] rusqlite::Error), + #[error("history event payload failed: {0}")] + Json(#[from] serde_json::Error), + #[error("history integer is outside the supported range: {0}")] + Integer(#[from] std::num::TryFromIntError), + #[error("unsupported history schema version {0}")] + UnsupportedSchema(i64), + #[error("invalid history data: {0}")] + Invalid(String), +} diff --git a/crates/history/tests/store_test.rs b/crates/history/tests/store_test.rs new file mode 100644 index 0000000..e426274 --- /dev/null +++ b/crates/history/tests/store_test.rs @@ -0,0 +1,549 @@ +use stellr_core::{ + HistoryEvent, HistoryEventKind, HistoryImportState, IssueSyncMetadata, MilestoneRef, +}; +use stellr_history::{HistoryStore, PageCheckpoint, RepositorySeed}; + +fn issue( + issue_id: &str, + number: u64, + created_at: i64, + updated_at: i64, + milestone: Option<(&str, &str)>, +) -> IssueSyncMetadata { + IssueSyncMetadata { + issue_id: issue_id.into(), + number, + created_at, + updated_at, + milestone: milestone.map(|(id, title)| MilestoneRef { + id: Some(id.into()), + title: title.into(), + }), + } +} + +#[test] +fn initializes_creation_history_idempotently_and_reads_ordered_deltas() { + let temp = tempfile::tempdir().unwrap(); + let store = HistoryStore::open(temp.path().join("history.sqlite3")).unwrap(); + let seed = RepositorySeed { + space_id: "octocat-hello".into(), + provider_repository_id: "R_repo".into(), + verified_through: 500, + timeline_required: false, + issues: vec![ + issue("I_later", 2, 200, 450, None), + issue("I_first", 1, 100, 400, Some(("M_v1", "v1"))), + ], + }; + + let first_summary = store.initialize_repository(&seed).unwrap(); + let first_events = store.events_after("octocat-hello", 0).unwrap(); + let repeated_summary = store.initialize_repository(&seed).unwrap(); + let repeated_events = store.events_after("octocat-hello", 0).unwrap(); + + assert_eq!(first_summary, repeated_summary); + assert_eq!(first_events, repeated_events); + assert_eq!(first_events.len(), 2); + assert_eq!(first_events[0].issue_number, 1); + assert_eq!(first_events[1].issue_number, 2); + assert!(matches!( + &first_events[0].kind, + HistoryEventKind::IssueCreated { + milestone: Some(milestone) + } if milestone.id.as_deref() == Some("M_v1") && milestone.title == "v1" + )); + assert_eq!(first_summary.state, HistoryImportState::Complete); + assert_eq!(first_summary.completed_issues, 2); + assert_eq!(first_summary.total_issues, 2); + assert_eq!(first_summary.earliest_event_at, Some(100)); + assert_eq!(first_summary.verified_through, Some(500)); + assert_eq!(first_summary.revision, first_events[1].sequence); + + assert_eq!( + store + .events_after("octocat-hello", first_events[0].sequence) + .unwrap(), + vec![first_events[1].clone()] + ); +} + +#[test] +fn removal_is_explicit_and_scoped_to_one_repository() { + let temp = tempfile::tempdir().unwrap(); + let store = HistoryStore::open(temp.path().join("history.sqlite3")).unwrap(); + for (space_id, repository_id, number) in [("one", "R_one", 1), ("two", "R_two", 2)] { + store + .initialize_repository(&RepositorySeed { + space_id: space_id.into(), + provider_repository_id: repository_id.into(), + verified_through: 500, + timeline_required: false, + issues: vec![issue(&format!("I_{number}"), number, 100, 200, None)], + }) + .unwrap(); + } + + assert!(store.remove_repository("one").unwrap()); + + assert!(store.summary("one").unwrap().is_none()); + assert!(store.events_after("one", 0).unwrap().is_empty()); + assert_eq!(store.events_after("two", 0).unwrap().len(), 1); + assert!(!store.remove_repository("missing").unwrap()); +} + +#[test] +fn checkpoints_lifecycle_pages_atomically_and_resumes_without_duplicates() { + let temp = tempfile::tempdir().unwrap(); + let store = HistoryStore::open(temp.path().join("history.sqlite3")).unwrap(); + let seed = RepositorySeed { + space_id: "octocat-hello".into(), + provider_repository_id: "R_repo".into(), + verified_through: 500, + timeline_required: true, + issues: vec![issue("I_1", 1, 100, 400, None)], + }; + + let building = store.initialize_repository(&seed).unwrap(); + let pending = store.pending_issue("octocat-hello").unwrap().unwrap(); + + assert_eq!(building.state, HistoryImportState::Building); + assert_eq!(building.completed_issues, 0); + assert_eq!(building.total_issues, 1); + assert_eq!(building.verified_through, None); + assert_eq!(pending.issue_id, "I_1"); + assert_eq!(pending.cursor, None); + assert_eq!(pending.cutoff, 500); + + let event = |provider_event_id: &str, occurred_at, kind| HistoryEvent { + sequence: 0, + repository_id: "R_repo".into(), + issue_id: "I_1".into(), + issue_number: 1, + provider_event_id: provider_event_id.into(), + occurred_at, + kind, + }; + let first_page = PageCheckpoint { + space_id: "octocat-hello".into(), + issue_id: "I_1".into(), + events: vec![ + event("E_reopen", 300, HistoryEventKind::IssueReopened), + event("E_close", 200, HistoryEventKind::IssueClosed), + event( + "E_milestone", + 250, + HistoryEventKind::MilestoneChanged { + from: None, + to: Some(MilestoneRef { + id: None, + title: "Alpha".into(), + }), + }, + ), + ], + next_cursor: Some("CUR2".into()), + resume_cursor: Some("CUR2".into()), + complete: false, + }; + + let page_summary = store.checkpoint_page(&first_page).unwrap(); + let first_revision = page_summary.revision; + let replayed = store.checkpoint_page(&first_page).unwrap(); + + assert_eq!(page_summary.state, HistoryImportState::Building); + assert_eq!(replayed.revision, first_revision); + assert_eq!( + store + .pending_issue("octocat-hello") + .unwrap() + .unwrap() + .cursor + .as_deref(), + Some("CUR2") + ); + + let imported = store + .checkpoint_page(&PageCheckpoint { + space_id: "octocat-hello".into(), + issue_id: "I_1".into(), + events: vec![], + next_cursor: None, + resume_cursor: Some("CUR_END".into()), + complete: true, + }) + .unwrap(); + let complete = store.initialize_repository(&seed).unwrap(); + let events = store.events_after("octocat-hello", 0).unwrap(); + + assert_eq!(imported.state, HistoryImportState::Building); + assert_eq!(complete.state, HistoryImportState::Complete); + assert_eq!(complete.completed_issues, 1); + assert_eq!(complete.verified_through, Some(500)); + assert!(store.pending_issue("octocat-hello").unwrap().is_none()); + assert_eq!( + events + .iter() + .map(|event| event.provider_event_id.as_str()) + .collect::>(), + vec!["I_1:issue_created", "E_close", "E_milestone", "E_reopen"] + ); + assert_eq!( + events[2].kind, + HistoryEventKind::MilestoneChanged { + from: None, + to: Some(MilestoneRef { + id: None, + title: "Alpha".into(), + }), + } + ); +} + +#[test] +fn interrupted_import_resumes_from_the_last_checkpoint_after_reopening() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("history.sqlite3"); + let store = HistoryStore::open(&path).unwrap(); + store + .initialize_repository(&RepositorySeed { + space_id: "octocat-hello".into(), + provider_repository_id: "R_repo".into(), + verified_through: 500, + timeline_required: true, + issues: vec![issue("I_1", 1, 100, 400, None)], + }) + .unwrap(); + store + .checkpoint_page(&PageCheckpoint { + space_id: "octocat-hello".into(), + issue_id: "I_1".into(), + events: vec![], + next_cursor: Some("CUR_PAGE_2".into()), + resume_cursor: Some("CUR_PAGE_2".into()), + complete: false, + }) + .unwrap(); + drop(store); + + let reopened = HistoryStore::open(&path).unwrap(); + let pending = reopened.pending_issue("octocat-hello").unwrap().unwrap(); + + assert_eq!(pending.issue_id, "I_1"); + assert_eq!(pending.cursor.as_deref(), Some("CUR_PAGE_2")); + assert_eq!(pending.cutoff, 500); +} + +#[test] +fn completed_ledgers_verify_once_then_request_only_new_or_changed_issues() { + let temp = tempfile::tempdir().unwrap(); + let store = HistoryStore::open(temp.path().join("history.sqlite3")).unwrap(); + let seed = |verified_through, issues| RepositorySeed { + space_id: "octocat-hello".into(), + provider_repository_id: "R_repo".into(), + verified_through, + timeline_required: true, + issues, + }; + + store + .initialize_repository(&seed(500, vec![issue("I_1", 1, 100, 400, None)])) + .unwrap(); + let imported = store + .checkpoint_page(&PageCheckpoint { + space_id: "octocat-hello".into(), + issue_id: "I_1".into(), + events: vec![], + next_cursor: None, + resume_cursor: Some("CUR_END".into()), + complete: true, + }) + .unwrap(); + assert_eq!(imported.state, HistoryImportState::Building); + assert!(store.pending_issue("octocat-hello").unwrap().is_none()); + + let verified = store + .initialize_repository(&seed(600, vec![issue("I_1", 1, 100, 400, None)])) + .unwrap(); + assert_eq!(verified.state, HistoryImportState::Complete); + assert_eq!(verified.verified_through, Some(600)); + assert!(store.pending_issue("octocat-hello").unwrap().is_none()); + + let unchanged = store + .initialize_repository(&seed(700, vec![issue("I_1", 1, 100, 400, None)])) + .unwrap(); + assert_eq!(unchanged.state, HistoryImportState::Complete); + assert_eq!(unchanged.verified_through, Some(700)); + assert!(store.pending_issue("octocat-hello").unwrap().is_none()); + + let changed = store + .initialize_repository(&seed( + 800, + vec![ + issue("I_1", 1, 100, 450, None), + issue("I_2", 2, 750, 750, None), + ], + )) + .unwrap(); + assert_eq!(changed.state, HistoryImportState::Building); + let first = store.pending_issue("octocat-hello").unwrap().unwrap(); + assert_eq!(first.issue_id, "I_1"); + assert_eq!(first.cursor.as_deref(), Some("CUR_END")); + assert_eq!(first.cutoff, 800); + + store + .checkpoint_page(&PageCheckpoint { + space_id: "octocat-hello".into(), + issue_id: "I_1".into(), + events: vec![], + next_cursor: None, + resume_cursor: Some("CUR_NEW".into()), + complete: true, + }) + .unwrap(); + let second = store.pending_issue("octocat-hello").unwrap().unwrap(); + assert_eq!(second.issue_id, "I_2"); + assert_eq!(second.cursor, None); +} + +#[test] +fn first_milestone_transition_corrects_present_day_creation_membership() { + let store = HistoryStore::open_in_memory().unwrap(); + let initial = store + .initialize_repository(&RepositorySeed { + space_id: "octocat-hello".into(), + provider_repository_id: "R_repo".into(), + verified_through: 500, + timeline_required: true, + issues: vec![issue("I_1", 1, 100, 400, Some(("M_now", "Now")))], + }) + .unwrap(); + let corrected = store + .checkpoint_page(&PageCheckpoint { + space_id: "octocat-hello".into(), + issue_id: "I_1".into(), + events: vec![HistoryEvent { + sequence: 0, + repository_id: "R_repo".into(), + issue_id: "I_1".into(), + issue_number: 1, + provider_event_id: "E_milestone".into(), + occurred_at: 200, + kind: HistoryEventKind::MilestoneChanged { + from: None, + to: Some(MilestoneRef { + id: None, + title: "Now".into(), + }), + }, + }], + next_cursor: None, + resume_cursor: Some("CUR_END".into()), + complete: true, + }) + .unwrap(); + + let creation = store.events_after("octocat-hello", 0).unwrap().remove(0); + assert_eq!( + creation.kind, + HistoryEventKind::IssueCreated { milestone: None } + ); + assert!(creation.sequence > initial.revision); + assert_eq!(corrected.revision, creation.sequence); +} + +#[test] +fn later_snapshot_pages_cannot_hide_activity_after_the_frozen_cutoff() { + let store = HistoryStore::open_in_memory().unwrap(); + let seed = |verified_through| RepositorySeed { + space_id: "octocat-hello".into(), + provider_repository_id: "R_repo".into(), + verified_through, + timeline_required: true, + issues: vec![issue("I_1", 1, 600, 600, None)], + }; + store.initialize_repository(&seed(500)).unwrap(); + assert!(store.events_after("octocat-hello", 0).unwrap().is_empty()); + store + .checkpoint_page(&PageCheckpoint { + space_id: "octocat-hello".into(), + issue_id: "I_1".into(), + events: vec![], + next_cursor: None, + resume_cursor: Some("CUR_BEFORE_FUTURE".into()), + complete: true, + }) + .unwrap(); + + let catch_up = store.initialize_repository(&seed(700)).unwrap(); + let pending = store.pending_issue("octocat-hello").unwrap().unwrap(); + + assert_eq!(catch_up.state, HistoryImportState::Building); + assert_eq!(pending.cursor.as_deref(), Some("CUR_BEFORE_FUTURE")); + assert_eq!(pending.cutoff, 700); + assert_eq!(store.events_after("octocat-hello", 0).unwrap().len(), 1); +} + +#[test] +fn rate_limit_evidence_preserves_pending_work_and_resume_time() { + let temp = tempfile::tempdir().unwrap(); + let store = HistoryStore::open(temp.path().join("history.sqlite3")).unwrap(); + store + .initialize_repository(&RepositorySeed { + space_id: "octocat-hello".into(), + provider_repository_id: "R_repo".into(), + verified_through: 500, + timeline_required: true, + issues: vec![issue("I_1", 1, 100, 400, None)], + }) + .unwrap(); + + let limited = store.mark_rate_limited("octocat-hello", Some(900)).unwrap(); + + assert_eq!(limited.state, HistoryImportState::RateLimited); + assert_eq!(limited.resume_at, Some(900)); + assert_eq!( + limited.diagnostic.as_deref(), + Some("GitHub rate limit exceeded") + ); + assert_eq!( + store + .pending_issue("octocat-hello") + .unwrap() + .unwrap() + .issue_id, + "I_1" + ); + + assert!(store.retry_repository("octocat-hello").unwrap()); + let retried = store.summary("octocat-hello").unwrap().unwrap(); + assert_eq!(retried.state, HistoryImportState::Building); + assert_eq!(retried.resume_at, None); + assert_eq!(retried.diagnostic, None); + assert!(store.pending_issue("octocat-hello").unwrap().is_some()); +} + +#[test] +fn schema_one_creation_ledgers_reopen_for_lifecycle_backfill() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("history.sqlite3"); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection + .execute_batch( + "PRAGMA user_version = 1; + PRAGMA foreign_keys = ON; + CREATE TABLE repositories ( + space_id TEXT PRIMARY KEY, + provider_repository_id TEXT NOT NULL, + import_state TEXT NOT NULL, + total_issues INTEGER NOT NULL, + completed_issues INTEGER NOT NULL, + verified_through INTEGER, + diagnostic TEXT, + resume_at INTEGER + ); + CREATE TABLE issues ( + space_id TEXT NOT NULL REFERENCES repositories(space_id) ON DELETE CASCADE, + provider_issue_id TEXT NOT NULL, + issue_number INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + milestone_id TEXT, + milestone_title TEXT, + cursor TEXT, + complete INTEGER NOT NULL, + PRIMARY KEY (space_id, provider_issue_id) + ); + CREATE TABLE events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + space_id TEXT NOT NULL REFERENCES repositories(space_id) ON DELETE CASCADE, + provider_event_id TEXT NOT NULL, + provider_issue_id TEXT NOT NULL, + issue_number INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE (space_id, provider_event_id) + ); + CREATE INDEX events_space_time + ON events(space_id, occurred_at, provider_event_id); + INSERT INTO repositories VALUES + ('octocat-hello', 'R_repo', 'complete', 1, 1, 500, NULL, NULL); + INSERT INTO issues VALUES + ('octocat-hello', 'I_1', 1, 100, 400, NULL, NULL, NULL, 1); + INSERT INTO events ( + space_id, provider_event_id, provider_issue_id, issue_number, + occurred_at, payload + ) VALUES ( + 'octocat-hello', 'I_1:issue_created', 'I_1', 1, 100, + '{\"kind\":\"issue_created\",\"milestone\":null}' + );", + ) + .unwrap(); + drop(connection); + + let store = HistoryStore::open(&path).unwrap(); + let summary = store.summary("octocat-hello").unwrap().unwrap(); + let pending = store.pending_issue("octocat-hello").unwrap().unwrap(); + + assert_eq!(summary.state, HistoryImportState::Building); + assert_eq!(summary.completed_issues, 0); + assert_eq!(summary.verified_through, None); + assert_eq!(pending.issue_id, "I_1"); + assert_eq!(pending.cutoff, 500); + assert_eq!(store.events_after("octocat-hello", 0).unwrap().len(), 1); +} + +#[test] +fn failed_migration_rolls_back_without_mutating_the_existing_ledger() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("history.sqlite3"); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection + .execute_batch( + "PRAGMA user_version = 2; + CREATE TABLE repositories ( + space_id TEXT PRIMARY KEY, + provider_repository_id TEXT NOT NULL, + import_state TEXT NOT NULL, + total_issues INTEGER NOT NULL, + completed_issues INTEGER NOT NULL, + verified_through INTEGER, + diagnostic TEXT, + resume_at INTEGER, + cutoff INTEGER, + catch_up_required INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + space_id TEXT NOT NULL, + provider_event_id TEXT NOT NULL, + provider_issue_id TEXT NOT NULL, + issue_number INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + payload TEXT NOT NULL + ); + INSERT INTO repositories VALUES + ('octocat-hello', 'R_repo', 'complete', 1, 1, 500, NULL, NULL, 500, 0); + INSERT INTO events ( + space_id, provider_event_id, provider_issue_id, issue_number, + occurred_at, payload + ) VALUES ( + 'octocat-hello', 'I_1:issue_created', 'I_1', 1, 100, + '{\"kind\":\"issue_created\",\"milestone\":null}' + );", + ) + .unwrap(); + drop(connection); + + assert!(HistoryStore::open(&path).is_err()); + + let connection = rusqlite::Connection::open(&path).unwrap(); + let version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + let events: i64 = connection + .query_row("SELECT COUNT(*) FROM events", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 2); + assert_eq!(events, 1); +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 1788789..ef9cf0b 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -15,6 +15,7 @@ serde.workspace = true serde_json.workspace = true stellr-core = { path = "../core" } stellr-github = { path = "../github" } +stellr-history = { path = "../history" } thiserror.workspace = true tokio.workspace = true subtle = "2" diff --git a/crates/server/src/poll.rs b/crates/server/src/poll.rs index 2138d47..f38b5df 100644 --- a/crates/server/src/poll.rs +++ b/crates/server/src/poll.rs @@ -1,11 +1,17 @@ use std::{sync::Arc, time::Duration}; use chrono::Utc; -use stellr_core::{Model, Provider, ProviderError, ProviderSnapshot, SpaceModel, derive}; +use stellr_core::{ + HistoryImportState, HistoryPageRequest, HistorySummary, Model, Provider, ProviderError, + ProviderSnapshot, SpaceModel, derive, +}; use stellr_github::cache::{Cache, Snapshot}; +use stellr_history::RepositorySeed; use crate::{spaces::SpaceEntry, state::AppState}; +const CONSERVATIVE_RATE_LIMIT_BACKOFF: i64 = 60; + #[derive(Clone)] pub struct PollingControl { intervals: tokio::sync::watch::Sender, @@ -98,40 +104,191 @@ async fn sync_spaces(state: &AppState, provider: &(dyn Provider + Send + Sync), let entries = state.spaces.lock().await.entries().to_vec(); let mut results = Vec::with_capacity(entries.len()); for entry in &entries { - results.push(provider.fetch(&entry.repo).await); + results.push(provider.fetch_snapshot(&entry.repo).await); } - let publication_generations: Vec = results - .iter() - .filter_map(|result| { - result - .as_ref() - .ok() - .and_then(ProviderSnapshot::publication_generation) - }) - .collect(); + let publication_generations = publication_generations(&results); 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)) + .map(|(entry, result)| sync_result(entry, result, provider, cache, &state.history)) .collect(); state.hub.send_replace(Model { spaces }); }; - if provider.commit_if_current(&publication_generations, &mut publish) { + let published = provider.commit_if_current(&publication_generations, &mut publish); + + if !published { + let mut publish_safe_fallback = || { + let spaces = entries + .iter() + .map(|entry| { + cached_model( + entry, + provider, + cache, + &state.history, + ProviderError::Superseded, + ) + }) + .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"); 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 }); + for entry in &entries { + import_pending_history(state, provider, entry).await; + if needs_catch_up(&state.history, &entry.id) { + let result = provider.fetch_snapshot(&entry.repo).await; + let generations = result + .as_ref() + .ok() + .and_then(ProviderSnapshot::publication_generation) + .into_iter() + .collect::>(); + let mut pending = Some(result); + let mut publish = || { + let space = sync_result( + entry, + pending.take().expect("provider commit runs once"), + provider, + cache, + &state.history, + ); + publish_space(state, space); + }; + if provider.commit_if_current(&generations, &mut publish) { + import_pending_history(state, provider, entry).await; + } + } + } +} + +fn publication_generations(results: &[Result]) -> Vec { + results + .iter() + .filter_map(|result| { + result + .as_ref() + .ok() + .and_then(ProviderSnapshot::publication_generation) + }) + .collect() +} + +fn needs_catch_up(history: &stellr_history::HistoryStore, space_id: &str) -> bool { + history + .summary(space_id) + .ok() + .flatten() + .is_some_and(|summary| { + summary.state == HistoryImportState::Building + && summary.total_issues > 0 + && summary.completed_issues == summary.total_issues + }) + && history.pending_issue(space_id).ok().flatten().is_none() +} + +async fn import_pending_history( + state: &AppState, + provider: &(dyn Provider + Send + Sync), + entry: &SpaceEntry, +) { + if state + .history + .summary(&entry.id) + .ok() + .flatten() + .is_some_and(|summary| { + summary.state == HistoryImportState::RateLimited + && summary + .resume_at + .is_some_and(|resume_at| resume_at > Utc::now().timestamp()) + }) + { + return; + } + loop { + let pending = match state.history.pending_issue(&entry.id) { + Ok(Some(pending)) => pending, + Ok(None) => return, + Err(error) => { + publish_history_summary(state, &entry.id, failed_history(error.to_string())); + return; + } + }; + let request = HistoryPageRequest { + issue_id: pending.issue_id.clone(), + issue_number: pending.issue_number, + cursor: pending.cursor.clone(), + cutoff: pending.cutoff, + }; + let page = match provider.fetch_history_page(&entry.repo, &request).await { + Ok(page) => page, + Err(error) => { + let summary = match error { + ProviderError::RateLimited { reset_epoch } => state + .history + .mark_rate_limited(&entry.id, Some(rate_limit_resume(reset_epoch))) + .unwrap_or_else(|store_error| failed_history(store_error.to_string())), + other => state + .history + .mark_failed(&entry.id, other.to_string()) + .unwrap_or_else(|store_error| failed_history(store_error.to_string())), + }; + publish_history_summary(state, &entry.id, summary); + return; + } + }; + let summary = match state + .history + .checkpoint_page(&stellr_history::PageCheckpoint { + space_id: entry.id.clone(), + issue_id: pending.issue_id, + events: page.events, + next_cursor: page.next_cursor, + resume_cursor: page.resume_cursor, + complete: page.complete, + }) { + Ok(summary) => summary, + Err(error) => { + let summary = state + .history + .mark_failed(&entry.id, error.to_string()) + .unwrap_or_else(|store_error| failed_history(store_error.to_string())); + publish_history_summary(state, &entry.id, summary); + return; + } + }; + publish_history_summary(state, &entry.id, summary); + tokio::task::yield_now().await; + } +} + +fn publish_space(state: &AppState, space: SpaceModel) { + let mut model = state.hub.borrow().clone(); + if let Some(existing) = model + .spaces + .iter_mut() + .find(|existing| existing.id == space.id) + { + *existing = space; + } else { + model.spaces.push(space); + } + state.hub.send_replace(model); +} + +fn publish_history_summary(state: &AppState, space_id: &str, summary: HistorySummary) { + let mut model = state.hub.borrow().clone(); + let Some(space) = model.spaces.iter_mut().find(|space| space.id == space_id) else { + return; }; - let committed = provider.commit_if_current(&[], &mut publish_safe_fallback); - debug_assert!(committed, "an empty fallback batch must be current"); + space.history = summary; + state.hub.send_replace(model); } fn sync_result( @@ -139,6 +296,7 @@ fn sync_result( result: Result, provider: &(dyn Provider + Send + Sync), cache: &Cache, + history_store: &stellr_history::HistoryStore, ) -> SpaceModel { match result { Ok(snapshot) => { @@ -152,6 +310,7 @@ fn sync_result( synced_at, }, ); + let history = history_after_snapshot(entry, &snapshot, history_store); model( entry, snapshot.issues, @@ -159,9 +318,10 @@ fn sync_result( Some(synced_at), false, None, + history, ) } - Err(error) => cached_model(entry, provider, cache, error.to_string()), + Err(error) => cached_model(entry, provider, cache, history_store, error), } } @@ -169,7 +329,8 @@ fn cached_model( entry: &SpaceEntry, provider: &(dyn Provider + Send + Sync), cache: &Cache, - error: String, + history_store: &stellr_history::HistoryStore, + error: ProviderError, ) -> SpaceModel { let snapshot = cache.load(&entry.repo); let (issues, cached_viewer_login, synced_at) = snapshot @@ -185,7 +346,80 @@ fn cached_model( .allows_cached_viewer_identity() .then_some(cached_viewer_login) .flatten(); - model(entry, issues, viewer_login, synced_at, true, Some(error)) + let stored_history = history_store + .summary(&entry.id) + .unwrap_or_else(|history_error| Some(failed_history(history_error.to_string()))) + .unwrap_or_default(); + let history = match &error { + ProviderError::RateLimited { reset_epoch } + if stored_history.state != HistoryImportState::Unavailable => + { + history_store + .mark_rate_limited(&entry.id, Some(rate_limit_resume(*reset_epoch))) + .unwrap_or(stored_history) + } + _ => stored_history, + }; + model( + entry, + issues, + viewer_login, + synced_at, + true, + Some(error.to_string()), + history, + ) +} + +fn rate_limit_resume(provider_reset: Option) -> i64 { + provider_reset.unwrap_or_else(|| { + Utc::now() + .timestamp() + .saturating_add(CONSERVATIVE_RATE_LIMIT_BACKOFF) + }) +} + +fn history_after_snapshot( + entry: &SpaceEntry, + snapshot: &stellr_core::ProviderSnapshot, + store: &stellr_history::HistoryStore, +) -> HistorySummary { + let Some(repository_id) = snapshot.repository_id.as_ref() else { + return store + .summary(&entry.id) + .unwrap_or_else(|error| Some(failed_history(error.to_string()))) + .unwrap_or_default(); + }; + let Some(verified_through) = snapshot.history_cutoff else { + let diagnostic = "provider snapshot omitted a history verification boundary".to_owned(); + return store + .mark_failed(&entry.id, diagnostic.clone()) + .unwrap_or_else(|_| failed_history(diagnostic)); + }; + if snapshot.history.len() != snapshot.issues.len() { + return failed_history(format!( + "provider returned history metadata for {}/{} issues", + snapshot.history.len(), + snapshot.issues.len() + )); + } + store + .initialize_repository(&RepositorySeed { + space_id: entry.id.clone(), + provider_repository_id: repository_id.clone(), + verified_through, + timeline_required: true, + issues: snapshot.history.clone(), + }) + .unwrap_or_else(|error| failed_history(error.to_string())) +} + +fn failed_history(diagnostic: String) -> HistorySummary { + HistorySummary { + state: HistoryImportState::Failed, + diagnostic: Some(diagnostic), + ..HistorySummary::default() + } } fn model( @@ -195,6 +429,7 @@ fn model( synced_at: Option, stale: bool, error: Option, + history: HistorySummary, ) -> SpaceModel { SpaceModel { id: entry.id.clone(), @@ -205,5 +440,22 @@ fn model( synced_at, stale, error, + history, + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::{CONSERVATIVE_RATE_LIMIT_BACKOFF, rate_limit_resume}; + + #[test] + fn missing_provider_reset_uses_a_bounded_conservative_backoff() { + let before = Utc::now().timestamp() + CONSERVATIVE_RATE_LIMIT_BACKOFF; + let resume = rate_limit_resume(None); + let after = Utc::now().timestamp() + CONSERVATIVE_RATE_LIMIT_BACKOFF; + + assert!((before..=after).contains(&resume)); } } diff --git a/crates/server/src/routes.rs b/crates/server/src/routes.rs index 08459b1..e7ab1f8 100644 --- a/crates/server/src/routes.rs +++ b/crates/server/src/routes.rs @@ -3,7 +3,7 @@ use std::{future::Future, sync::Arc, time::Duration}; use axum::{ Json, Router, extract::{ - Path, Request, State, + Path, Query, Request, State, ws::{Message, WebSocket, WebSocketUpgrade}, }, http::{HeaderMap, HeaderValue, StatusCode, header}, @@ -12,7 +12,7 @@ use axum::{ routing::{delete, get, post}, }; use serde::{Deserialize, Serialize}; -use stellr_core::Model; +use stellr_core::{HistoryEvent, HistoryImportState, HistorySummary, Model}; use subtle::ConstantTimeEq; use crate::{ @@ -29,6 +29,7 @@ pub fn router(state: Arc) -> Router { .route("/api/spaces", post(add_space)) .route("/api/spaces/{id}", delete(remove_space)) .route("/api/spaces/{id}/refresh", post(refresh_space)) + .route("/api/spaces/{id}/history", get(history)) .route("/ws/control", get(control_ws)) .layer(middleware::from_fn_with_state(state.clone(), auth)); @@ -38,6 +39,60 @@ pub fn router(state: Arc) -> Router { .with_state(state) } +#[derive(Debug, Default, Deserialize)] +struct HistoryQuery { + after: Option, +} + +#[derive(Serialize)] +struct HistoryResponse { + summary: HistorySummary, + events: Vec, +} + +async fn history( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Response { + let known = state + .spaces + .lock() + .await + .entries() + .iter() + .any(|entry| entry.id == id); + if !known { + return StatusCode::NOT_FOUND.into_response(); + } + let summary = match state.history.summary(&id) { + Ok(summary) => summary.unwrap_or_default(), + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("could not read history summary: {error}"), + ) + .into_response(); + } + }; + let events = + if summary.state == HistoryImportState::Complete || summary.verified_through.is_some() { + match state.history.events_after(&id, query.after.unwrap_or(0)) { + Ok(events) => events, + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("could not read history events: {error}"), + ) + .into_response(); + } + } + } else { + Vec::new() + }; + Json(HistoryResponse { summary, events }).into_response() +} + async fn remove_space(State(state): State>, Path(id): Path) -> Response { let mut spaces = state.spaces.lock().await; let Some(entry) = spaces @@ -58,6 +113,13 @@ async fn remove_space(State(state): State>, Path(id): Path .into_response(); } drop(spaces); + if let Err(error) = state.history.remove_repository(&id) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("space was removed, but its history could not be deleted: {error}"), + ) + .into_response(); + } state.refresh.notify_one(); StatusCode::NO_CONTENT.into_response() } @@ -127,6 +189,9 @@ async fn refresh_space(State(state): State>, Path(id): Path, pub spaces: Mutex, pub refresh: Arc, + pub history: HistoryStore, } diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index 93f1ac5..d3388bb 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -1,6 +1,6 @@ use std::{ sync::{ - Arc, + Arc, Mutex, atomic::{AtomicUsize, Ordering}, }, time::Duration, @@ -9,9 +9,12 @@ use std::{ use futures_util::{SinkExt, StreamExt}; use serde_json::json; use stellr_core::{ - IssueState, Model, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef, SpaceModel, + HistoryEvent, HistoryEventKind, HistoryImportState, HistoryPage, HistoryPageRequest, + IssueState, IssueSyncMetadata, Model, Provider, ProviderError, ProviderSnapshot, RawIssue, + RepoRef, SpaceModel, }; use stellr_github::cache::{Cache, Snapshot}; +use stellr_history::{HistoryStore, PageCheckpoint, RepositorySeed}; use stellr_server::{ poll::spawn_poller, spaces::{SpaceEntry, SpaceStore}, @@ -54,6 +57,7 @@ fn state_with_hub(hub: tokio::sync::watch::Sender, token: Option<&str>) - token: token.map(str::to_owned), spaces: tokio::sync::Mutex::new(SpaceStore::load(std::path::PathBuf::new())), refresh: Arc::new(tokio::sync::Notify::new()), + history: HistoryStore::open_in_memory().unwrap(), }) } @@ -72,6 +76,7 @@ fn model_with_space(id: &str) -> Model { synced_at: None, stale: false, error: None, + history: Default::default(), }], } } @@ -151,6 +156,687 @@ impl Provider for StubProvider { } } +struct DeltaSnapshotProvider { + snapshots: AtomicUsize, + history_requests: Mutex>, +} + +impl DeltaSnapshotProvider { + fn new() -> Self { + Self { + snapshots: AtomicUsize::new(0), + history_requests: Mutex::new(Vec::new()), + } + } +} + +#[async_trait::async_trait] +impl Provider for DeltaSnapshotProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result { + let snapshot = self.snapshots.fetch_add(1, Ordering::SeqCst); + let updated_at = if snapshot >= 3 { 500 } else { 400 }; + Ok(ProviderSnapshot::with_history( + None, + Some("R_repo".into()), + Some(500), + vec![RawIssue { + number: 83, + parent_issue: None, + title: "Delta history".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec![], + milestone: None, + labels: vec![], + blocked_by: vec![], + url: "https://github.com/o/r/issues/83".into(), + }], + vec![IssueSyncMetadata { + issue_id: "I_83".into(), + number: 83, + created_at: 100, + updated_at, + milestone: None, + }], + )) + } + + async fn fetch_history_page( + &self, + _repo: &RepoRef, + request: &HistoryPageRequest, + ) -> Result { + self.history_requests.lock().unwrap().push(request.clone()); + let events = if request.cursor.as_deref() == Some("CUR_END") { + vec![ + HistoryEvent { + sequence: 0, + repository_id: "R_repo".into(), + issue_id: "I_83".into(), + issue_number: 83, + provider_event_id: "E_close".into(), + occurred_at: request.cutoff, + kind: HistoryEventKind::IssueClosed, + }, + HistoryEvent { + sequence: 0, + repository_id: "R_repo".into(), + issue_id: "I_83".into(), + issue_number: 83, + provider_event_id: "E_reopen".into(), + occurred_at: request.cutoff, + kind: HistoryEventKind::IssueReopened, + }, + ] + } else { + Vec::new() + }; + Ok(HistoryPage { + events, + next_cursor: None, + resume_cursor: Some(if request.cursor.is_some() { + "CUR_NEW".into() + } else { + "CUR_END".into() + }), + complete: true, + }) + } +} + +#[tokio::test] +async fn history_endpoint_is_authenticated_scoped_and_sequence_incremental() { + let state = state(Some("session-token")); + state + .spaces + .lock() + .await + .add(SpaceEntry::new( + RepoRef { + owner: "o".into(), + name: "r".into(), + }, + None, + )) + .unwrap(); + state + .history + .initialize_repository(&RepositorySeed { + space_id: "o-r".into(), + provider_repository_id: "R_repo".into(), + verified_through: 500, + timeline_required: false, + issues: vec![ + IssueSyncMetadata { + issue_id: "I_2".into(), + number: 2, + created_at: 200, + updated_at: 300, + milestone: None, + }, + IssueSyncMetadata { + issue_id: "I_1".into(), + number: 1, + created_at: 100, + updated_at: 300, + milestone: None, + }, + ], + }) + .unwrap(); + let base = serve(state).await; + let client = reqwest::Client::new(); + + let unauthorized = client + .get(format!("{base}/api/spaces/o-r/history")) + .send() + .await + .unwrap(); + assert_eq!(unauthorized.status(), reqwest::StatusCode::UNAUTHORIZED); + + let full = client + .get(format!("{base}/api/spaces/o-r/history")) + .bearer_auth("session-token") + .send() + .await + .unwrap(); + assert_eq!(full.status(), reqwest::StatusCode::OK); + let full: serde_json::Value = full.json().await.unwrap(); + assert_eq!(full["summary"]["state"], "complete"); + assert_eq!(full["events"].as_array().unwrap().len(), 2); + assert_eq!(full["events"][0]["issue_number"], 1); + assert_eq!(full["events"][1]["issue_number"], 2); + assert!(full.get("database_path").is_none()); + assert!(full.get("provider_response").is_none()); + + let first_sequence = full["events"][0]["sequence"].as_u64().unwrap(); + let delta: serde_json::Value = client + .get(format!( + "{base}/api/spaces/o-r/history?after={first_sequence}" + )) + .bearer_auth("session-token") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(delta["events"].as_array().unwrap().len(), 1); + assert_eq!(delta["events"][0]["issue_number"], 2); + + let unknown = client + .get(format!("{base}/api/spaces/unknown/history")) + .bearer_auth("session-token") + .send() + .await + .unwrap(); + assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn history_endpoint_serves_new_deltas_while_catch_up_verification_is_building() { + let state = state(None); + state + .spaces + .lock() + .await + .add(SpaceEntry::new( + RepoRef { + owner: "o".into(), + name: "r".into(), + }, + None, + )) + .unwrap(); + let metadata = |updated_at| IssueSyncMetadata { + issue_id: "I_1".into(), + number: 1, + created_at: 100, + updated_at, + milestone: None, + }; + let seed = |verified_through, updated_at| RepositorySeed { + space_id: "o-r".into(), + provider_repository_id: "R_repo".into(), + verified_through, + timeline_required: true, + issues: vec![metadata(updated_at)], + }; + state + .history + .initialize_repository(&seed(500, 400)) + .unwrap(); + state + .history + .checkpoint_page(&PageCheckpoint { + space_id: "o-r".into(), + issue_id: "I_1".into(), + events: vec![], + next_cursor: None, + resume_cursor: Some("CUR_END".into()), + complete: true, + }) + .unwrap(); + state + .history + .initialize_repository(&seed(500, 400)) + .unwrap(); + let first_sequence = state.history.summary("o-r").unwrap().unwrap().revision; + + state + .history + .initialize_repository(&seed(700, 500)) + .unwrap(); + state + .history + .checkpoint_page(&PageCheckpoint { + space_id: "o-r".into(), + issue_id: "I_1".into(), + events: vec![HistoryEvent { + sequence: 0, + repository_id: "R_repo".into(), + issue_id: "I_1".into(), + issue_number: 1, + provider_event_id: "E_close".into(), + occurred_at: 600, + kind: HistoryEventKind::IssueClosed, + }], + next_cursor: None, + resume_cursor: Some("CUR_NEW".into()), + complete: true, + }) + .unwrap(); + + let base = serve(state).await; + let delta: serde_json::Value = reqwest::get(format!( + "{base}/api/spaces/o-r/history?after={first_sequence}" + )) + .await + .unwrap() + .json() + .await + .unwrap(); + + assert_eq!(delta["summary"]["state"], "building"); + assert_eq!(delta["summary"]["verified_through"], 500); + assert_eq!(delta["events"].as_array().unwrap().len(), 1); + assert_eq!(delta["events"][0]["provider_event_id"], "E_close"); +} + +struct HistorySnapshotProvider; + +#[async_trait::async_trait] +impl Provider for HistorySnapshotProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::with_history( + None, + Some("R_repo".into()), + Some(500), + vec![RawIssue { + number: 78, + parent_issue: None, + title: "History".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec![], + milestone: None, + labels: vec![], + blocked_by: vec![], + url: "https://github.com/o/r/issues/78".into(), + }], + vec![IssueSyncMetadata { + issue_id: "I_78".into(), + number: 78, + created_at: 100, + updated_at: 200, + milestone: None, + }], + )) + } + + async fn fetch_history_page( + &self, + _repo: &RepoRef, + request: &HistoryPageRequest, + ) -> Result { + Ok(HistoryPage { + events: vec![], + next_cursor: None, + resume_cursor: request.cursor.clone(), + complete: true, + }) + } +} + +struct LifecycleSnapshotProvider(AtomicUsize); + +struct RateLimitedSnapshotProvider; + +#[async_trait::async_trait] +impl Provider for RateLimitedSnapshotProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::with_history( + None, + Some("R_repo".into()), + Some(500), + vec![RawIssue { + number: 83, + parent_issue: None, + title: "Rate limited history".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec![], + milestone: None, + labels: vec![], + blocked_by: vec![], + url: "https://github.com/o/r/issues/83".into(), + }], + vec![IssueSyncMetadata { + issue_id: "I_83".into(), + number: 83, + created_at: 100, + updated_at: 400, + milestone: None, + }], + )) + } + + async fn fetch_history_page( + &self, + _repo: &RepoRef, + _request: &HistoryPageRequest, + ) -> Result { + Err(ProviderError::RateLimited { + reset_epoch: Some(2_000_000_000), + }) + } +} + +#[async_trait::async_trait] +impl Provider for LifecycleSnapshotProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::with_history( + None, + Some("R_repo".into()), + Some(500), + vec![RawIssue { + number: 79, + parent_issue: None, + title: "Lifecycle".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec![], + milestone: None, + labels: vec![], + blocked_by: vec![], + url: "https://github.com/o/r/issues/79".into(), + }], + vec![IssueSyncMetadata { + issue_id: "I_79".into(), + number: 79, + created_at: 100, + updated_at: 400, + milestone: None, + }], + )) + } + + async fn fetch_history_page( + &self, + _repo: &RepoRef, + request: &HistoryPageRequest, + ) -> Result { + assert_eq!(request.issue_id, "I_79"); + assert_eq!(request.issue_number, 79); + assert_eq!(request.cursor, None); + self.0.fetch_add(1, Ordering::SeqCst); + Ok(HistoryPage { + events: vec![ + HistoryEvent { + sequence: 0, + repository_id: "R_repo".into(), + issue_id: "I_79".into(), + issue_number: 79, + provider_event_id: "E_close".into(), + occurred_at: 200, + kind: HistoryEventKind::IssueClosed, + }, + HistoryEvent { + sequence: 0, + repository_id: "R_repo".into(), + issue_id: "I_79".into(), + issue_number: 79, + provider_event_id: "E_reopen".into(), + occurred_at: 300, + kind: HistoryEventKind::IssueReopened, + }, + ], + next_cursor: None, + resume_cursor: Some("CUR_END".into()), + complete: true, + }) + } +} + +#[tokio::test] +async fn successful_current_sync_seeds_creation_history_and_publishes_its_summary() { + let directory = tempfile::tempdir().unwrap(); + let mut spaces = SpaceStore::load(directory.path().join("spaces.toml")); + spaces + .add(SpaceEntry::new( + RepoRef { + owner: "o".into(), + name: "r".into(), + }, + None, + )) + .unwrap(); + let (hub, mut receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); + let history = HistoryStore::open(directory.path().join("history.sqlite3")).unwrap(); + let state = Arc::new(AppState { + hub, + token: None, + spaces: tokio::sync::Mutex::new(spaces), + refresh: Arc::new(tokio::sync::Notify::new()), + history: history.clone(), + }); + + let poller = spawn_poller( + state, + Arc::new(HistorySnapshotProvider), + Cache::new(directory.path().join("cache")), + Duration::from_secs(60), + ); + let model = tokio::time::timeout(Duration::from_secs(10), async { + loop { + receiver + .changed() + .await + .expect("the model hub should stay open"); + let model = receiver.borrow().clone(); + if model.spaces[0].history.state == HistoryImportState::Complete { + break model; + } + } + }) + .await + .expect("the startup import and catch-up verification should publish"); + assert_eq!(model.spaces[0].stars[0].number, 78); + assert_eq!(model.spaces[0].history.state, HistoryImportState::Complete); + assert_eq!(model.spaces[0].history.completed_issues, 1); + assert_eq!(history.events_after("o-r", 0).unwrap().len(), 1); + + poller.abort(); +} + +#[tokio::test] +async fn lifecycle_import_publishes_building_then_complete_without_blocking_current_stars() { + let directory = tempfile::tempdir().unwrap(); + let mut spaces = SpaceStore::load(directory.path().join("spaces.toml")); + spaces + .add(SpaceEntry::new( + RepoRef { + owner: "o".into(), + name: "r".into(), + }, + None, + )) + .unwrap(); + let (hub, mut receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); + let history = HistoryStore::open(directory.path().join("history.sqlite3")).unwrap(); + let state = Arc::new(AppState { + hub, + token: None, + spaces: tokio::sync::Mutex::new(spaces), + refresh: Arc::new(tokio::sync::Notify::new()), + history: history.clone(), + }); + let provider = Arc::new(LifecycleSnapshotProvider(AtomicUsize::new(0))); + + let poller = spawn_poller( + state, + provider.clone(), + Cache::new(directory.path().join("cache")), + Duration::from_secs(60), + ); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + receiver.changed().await.unwrap(); + let model = receiver.borrow().clone(); + assert_eq!(model.spaces[0].stars[0].number, 79); + if model.spaces[0].history.state == HistoryImportState::Complete { + break; + } + assert_eq!(model.spaces[0].history.state, HistoryImportState::Building); + } + }) + .await + .expect("the lifecycle import should complete"); + + assert_eq!(provider.0.load(Ordering::SeqCst), 1); + assert_eq!( + history + .events_after("o-r", 0) + .unwrap() + .into_iter() + .map(|event| event.kind) + .collect::>(), + vec![ + HistoryEventKind::IssueCreated { milestone: None }, + HistoryEventKind::IssueClosed, + HistoryEventKind::IssueReopened, + ] + ); + + poller.abort(); +} + +#[tokio::test] +async fn history_rate_limit_publishes_reset_evidence_without_discarding_pending_work() { + let directory = tempfile::tempdir().unwrap(); + let mut spaces = SpaceStore::load(directory.path().join("spaces.toml")); + spaces + .add(SpaceEntry::new( + RepoRef { + owner: "o".into(), + name: "r".into(), + }, + None, + )) + .unwrap(); + let (hub, mut receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); + let history = HistoryStore::open(directory.path().join("history.sqlite3")).unwrap(); + let state = Arc::new(AppState { + hub, + token: None, + spaces: tokio::sync::Mutex::new(spaces), + refresh: Arc::new(tokio::sync::Notify::new()), + history: history.clone(), + }); + let poller = spawn_poller( + state, + Arc::new(RateLimitedSnapshotProvider), + Cache::new(directory.path().join("cache")), + Duration::from_secs(60), + ); + + let summary = tokio::time::timeout(Duration::from_secs(10), async { + loop { + receiver.changed().await.unwrap(); + let summary = receiver.borrow().spaces[0].history.clone(); + if summary.state == HistoryImportState::RateLimited { + break summary; + } + } + }) + .await + .expect("rate limit evidence should be published"); + + assert_eq!(summary.resume_at, Some(2_000_000_000)); + assert_eq!( + summary.diagnostic.as_deref(), + Some("GitHub rate limit exceeded") + ); + assert!(history.pending_issue("o-r").unwrap().is_some()); + poller.abort(); +} + +#[tokio::test] +async fn unchanged_polls_make_zero_history_requests_and_changed_issues_resume_from_cursor() { + let directory = tempfile::tempdir().unwrap(); + let mut spaces = SpaceStore::load(directory.path().join("spaces.toml")); + spaces + .add(SpaceEntry::new( + RepoRef { + owner: "o".into(), + name: "r".into(), + }, + None, + )) + .unwrap(); + let (hub, _receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); + let history = HistoryStore::open(directory.path().join("history.sqlite3")).unwrap(); + let state = Arc::new(AppState { + hub, + token: None, + spaces: tokio::sync::Mutex::new(spaces), + refresh: Arc::new(tokio::sync::Notify::new()), + history: history.clone(), + }); + let provider = Arc::new(DeltaSnapshotProvider::new()); + let poller = spawn_poller( + state.clone(), + provider.clone(), + Cache::new(directory.path().join("cache")), + Duration::from_secs(60), + ); + + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if provider.snapshots.load(Ordering::SeqCst) >= 2 + && history + .summary("o-r") + .unwrap() + .is_some_and(|summary| summary.state == HistoryImportState::Complete) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("initial import should receive catch-up verification"); + assert_eq!(provider.history_requests.lock().unwrap().len(), 1); + + state.refresh.notify_one(); + tokio::time::timeout(Duration::from_secs(10), async { + while provider.snapshots.load(Ordering::SeqCst) < 3 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("unchanged current snapshot should complete"); + assert_eq!(provider.history_requests.lock().unwrap().len(), 1); + + state.refresh.notify_one(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if provider.snapshots.load(Ordering::SeqCst) >= 5 + && provider.history_requests.lock().unwrap().len() == 2 + && history + .summary("o-r") + .unwrap() + .is_some_and(|summary| summary.state == HistoryImportState::Complete) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("changed issue should import once and receive catch-up verification"); + + let requests = provider.history_requests.lock().unwrap(); + assert_eq!(requests[0].cursor, None); + assert_eq!(requests[1].cursor.as_deref(), Some("CUR_END")); + drop(requests); + assert_eq!( + history + .events_after("o-r", 0) + .unwrap() + .into_iter() + .map(|event| event.kind) + .collect::>(), + vec![ + HistoryEventKind::IssueCreated { milestone: None }, + HistoryEventKind::IssueClosed, + HistoryEventKind::IssueReopened, + ] + ); + + poller.abort(); +} + struct FailingProvider; #[async_trait::async_trait] @@ -205,6 +891,7 @@ async fn add_repo_space_immediately_populates_the_model() { token: None, spaces: tokio::sync::Mutex::new(SpaceStore::load(directory.path().join("spaces.toml"))), refresh: Arc::new(tokio::sync::Notify::new()), + history: HistoryStore::open_in_memory().unwrap(), }); let poller = spawn_poller( state.clone(), @@ -308,11 +995,28 @@ async fn failed_sync_publishes_the_cached_model_as_stale_with_the_error() { spaces.add(SpaceEntry::new(repo, None)).unwrap(); spaces.save().unwrap(); let (hub, _receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); + let history = HistoryStore::open_in_memory().unwrap(); + history + .initialize_repository(&RepositorySeed { + space_id: "o-r".into(), + provider_repository_id: "R_repo".into(), + verified_through: 1_753_000_000, + timeline_required: false, + issues: vec![IssueSyncMetadata { + issue_id: "I_7".into(), + number: 7, + created_at: 1_752_000_000, + updated_at: 1_752_000_000, + milestone: None, + }], + }) + .unwrap(); let state = Arc::new(AppState { hub, token: None, spaces: tokio::sync::Mutex::new(spaces), refresh: Arc::new(tokio::sync::Notify::new()), + history: history.clone(), }); let poller = spawn_poller( state.clone(), @@ -350,6 +1054,15 @@ async fn failed_sync_publishes_the_cached_model_as_stale_with_the_error() { model.spaces[0].error.as_deref(), Some("HTTP request failed: offline") ); + assert_eq!(model.spaces[0].history.state, HistoryImportState::Complete); + assert_eq!(history.events_after("o-r", 0).unwrap().len(), 1); + let replay = reqwest::get(format!("{base}/api/spaces/o-r/history")) + .await + .unwrap(); + assert_eq!(replay.status(), reqwest::StatusCode::OK); + let replay: serde_json::Value = replay.json().await.unwrap(); + assert_eq!(replay["summary"]["state"], "complete"); + assert_eq!(replay["events"].as_array().unwrap().len(), 1); poller.abort(); } @@ -390,6 +1103,7 @@ async fn failed_unconfirmed_provider_keeps_cached_issues_but_suppresses_cached_v token: None, spaces: tokio::sync::Mutex::new(spaces), refresh: Arc::new(tokio::sync::Notify::new()), + history: HistoryStore::open_in_memory().unwrap(), }); let poller = spawn_poller( state, @@ -431,6 +1145,7 @@ async fn successful_sync_stays_fresh_when_the_cache_cannot_be_written() { token: None, spaces: tokio::sync::Mutex::new(spaces), refresh: Arc::new(tokio::sync::Notify::new()), + history: HistoryStore::open_in_memory().unwrap(), }); let poller = spawn_poller( state, @@ -455,11 +1170,13 @@ async fn delete_space_removes_the_persisted_entry() { let directory = tempfile::tempdir().unwrap(); let file = directory.path().join("spaces.toml"); let (hub, _receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); + let history = HistoryStore::open_in_memory().unwrap(); let state = Arc::new(AppState { hub, token: None, spaces: tokio::sync::Mutex::new(SpaceStore::load(file.clone())), refresh: Arc::new(tokio::sync::Notify::new()), + history: history.clone(), }); let base = serve(state).await; let client = reqwest::Client::new(); @@ -473,6 +1190,21 @@ async fn delete_space_removes_the_persisted_entry() { .status(), reqwest::StatusCode::OK ); + history + .initialize_repository(&RepositorySeed { + space_id: "o-r".into(), + provider_repository_id: "R_repo".into(), + verified_through: 500, + timeline_required: false, + issues: vec![IssueSyncMetadata { + issue_id: "I_1".into(), + number: 1, + created_at: 100, + updated_at: 200, + milestone: None, + }], + }) + .unwrap(); let deleted = client .delete(format!("{base}/api/spaces/o-r")) @@ -482,6 +1214,7 @@ async fn delete_space_removes_the_persisted_entry() { assert_eq!(deleted.status(), reqwest::StatusCode::NO_CONTENT); assert!(SpaceStore::load(file).entries().is_empty()); + assert!(history.summary("o-r").unwrap().is_none()); } #[tokio::test] @@ -517,6 +1250,7 @@ async fn add_path_space_detects_and_persists_the_github_origin() { token: None, spaces: tokio::sync::Mutex::new(SpaceStore::load(file.clone())), refresh: Arc::new(tokio::sync::Notify::new()), + history: HistoryStore::open_in_memory().unwrap(), }); let base = serve(state).await; @@ -560,6 +1294,7 @@ async fn poller_repeats_on_the_configured_interval() { token: None, spaces: tokio::sync::Mutex::new(spaces), refresh: Arc::new(tokio::sync::Notify::new()), + history: HistoryStore::open_in_memory().unwrap(), }); let poller = spawn_poller( state.clone(), diff --git a/crates/server/tests/focus_polling_test.rs b/crates/server/tests/focus_polling_test.rs index 77dffd5..fbb8b5a 100644 --- a/crates/server/tests/focus_polling_test.rs +++ b/crates/server/tests/focus_polling_test.rs @@ -5,6 +5,7 @@ use std::sync::{ use stellr_core::{Model, Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::cache::Cache; +use stellr_history::HistoryStore; use stellr_server::{ poll::{PollingControl, spawn_controlled_poller}, spaces::{SpaceEntry, SpaceStore}, @@ -50,6 +51,7 @@ async fn focus_transitions_reschedule_one_poller_and_manual_refresh_stays_immedi token: None, spaces: tokio::sync::Mutex::new(spaces), refresh: Arc::new(tokio::sync::Notify::new()), + history: HistoryStore::open_in_memory().unwrap(), }); let calls = Arc::new(AtomicUsize::new(0)); let control = PollingControl::focus_aware( diff --git a/docs/superpowers/plans/2026-08-04-temporal-history-slider.md b/docs/superpowers/plans/2026-08-04-temporal-history-slider.md new file mode 100644 index 0000000..5348738 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-temporal-history-slider.md @@ -0,0 +1,292 @@ +# Temporal Issue-History Slider 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:** Add a bottom-hovering, locally replayable issue-history timeline for creation, close/reopen, and milestone activity while keeping the constellation spatially stable and minimizing GitHub retrieval. + +**Architecture:** Keep the current snapshot and the historical event ledger separate. Add provider-neutral temporal types to `stellr-core`, a transactional SQLite ledger in a new `stellr-history` crate, targeted GitHub timeline retrieval in `stellr-github`, authenticated summary/delta delivery in `stellr-server`, and a fully local Svelte projection/playback layer that feeds the existing renderer a structurally stable constellation plus temporal visual overlays. + +**Tech Stack:** Rust 2024, Axum, Tokio, rusqlite (bundled SQLite), GitHub GraphQL, Svelte 5, TypeScript, Vitest, WireMock, native Windows PowerShell. + +## Global Constraints + +- Work only in native Windows tooling from `D:\tmp\stellr-issues-78-84`; do not use WSL or Linux paths. +- Implement and commit the dependency chain in order: #78, #79, #81, #82, #83, #84. +- Keep all six tickets on `codex/issues-78-84-temporal-history`; preserve the dirty primary checkout. +- Treat `docs/superpowers/specs/2026-08-04-temporal-history-slider-design.md` as the product contract. +- Use public behavior seams: domain projection, ledger methods, provider HTTP requests/results, authenticated HTTP responses, accessible DOM interactions, and renderer positions/camera/selection. +- Do not test private SQL tables or private canvas fields. Use a controllable clock for playback and provider timestamps for history. +- Run the smallest relevant Rust test target or Vitest file after each red/green cycle. Run full frontend and Rust suites once after #84. +- Keep `CHANGELOG.md` newest-first and append each pending change under `Unreleased`; do not edit shipped release sections. +- Scrubbing and playback must never call GitHub. Initial history is complete before controls enable. Completed history is never broadly fetched again. +- Never move the map camera, selection, detail pane, or star world coordinates as a result of temporal playback. + +--- + +## Task 1: Issue #78 — Durable creation ledger and creation-time scrubbing + +**Files:** + +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Create: `crates/history/Cargo.toml` +- Create: `crates/history/src/lib.rs` +- Create: `crates/history/src/store.rs` +- Create: `crates/history/tests/store_test.rs` +- Create: `crates/core/src/history.rs` +- Modify: `crates/core/src/lib.rs` +- Modify: `crates/core/src/model.rs` +- Modify: `crates/core/src/provider.rs` +- Modify: `crates/core/tests/model_contract_test.rs` +- Modify: `crates/github/src/sync.rs` +- Modify: `crates/github/tests/sync_test.rs` +- Modify: `crates/server/Cargo.toml` +- Modify: `crates/server/src/state.rs` +- Modify: `crates/server/src/poll.rs` +- Modify: `crates/server/src/routes.rs` +- Modify: `crates/server/tests/api_test.rs` +- Modify: `crates/app/Cargo.toml` +- Modify: `crates/app/src/runtime.rs` +- Modify: `web/src/lib/model.ts` +- Create: `web/src/lib/history.ts` +- Create: `web/src/lib/history.test.ts` +- Create: `web/src/lib/history-api.ts` +- Create: `web/src/lib/TemporalTimeline.svelte` +- Create: `web/src/lib/TemporalTimeline.test.ts` +- Modify: `web/src/App.svelte` +- Modify: `web/src/App.test.ts` +- Modify: `CHANGELOG.md` + +### 1.1 Define provider-neutral history contracts + +- [ ] Add a failing serialization/projection contract test for `HistoryEvent`, `HistoryEventKind::IssueCreated`, `HistorySummary`, `HistoryImportState`, `IssueSyncMetadata`, `ProviderSnapshot`, `HistoryPageRequest`, and `HistoryPage`. +- [ ] Add `crates/core/src/history.rs` with stable repository/issue/event IDs, UTC epoch-second timestamps, deterministic `(occurred_at, provider_event_id)` ordering, minimal milestone payloads, progress/diagnostic fields, and ledger sequence numbers. +- [ ] Extend `Provider` with a default `fetch_snapshot` that wraps existing `fetch`, plus a default unsupported targeted-history method so existing provider stubs remain source-compatible. +- [ ] Add `history: HistorySummary` to `SpaceModel` and use a default unavailable summary for old/current-only models. +- [ ] Run `cargo.exe test -p stellr-core --locked`. + +### 1.2 Add the transactional SQLite ledger + +- [ ] Add failing `store_test` cases through `HistoryStore` for fixed transactional migration, bound repository lookup, deterministic event ordering, unique repository/event identity, repeated-page idempotence, atomic event/cursor/progress commits, summary calculation, and repository deletion. +- [ ] Create `stellr-history` using `rusqlite` with bundled SQLite. Store repository/issue identities, events, per-issue cursors and `updatedAt`, import state/progress, verified-through, retry evidence, and a monotonic ledger sequence; never store tokens, bodies, comments, headers, or raw responses. +- [ ] Keep each page checkpoint in one transaction and expose `events_after(repository, sequence)` rather than SQL details. +- [ ] Run `cargo.exe test -p stellr-history --locked --test store_test`. + +### 1.3 Piggyback stable IDs and timestamps on the current issue request + +- [ ] Add failing WireMock assertions that the existing paginated issue-list GraphQL query requests repository ID and issue `id`, `createdAt`, and `updatedAt` without adding a second repository-wide request. +- [ ] Update `GithubProvider` to return a `ProviderSnapshot` with current `RawIssue` values and the minimal history index metadata. Preserve `fetch` behavior for callers that only need current state. +- [ ] Normalize a deterministic creation event ID from the provider issue ID plus `issue_created`. +- [ ] Run `cargo.exe test -p stellr-github --locked --test sync_test`. + +### 1.4 Import creations after the first successful current sync + +- [ ] Add failing server polling tests for creating the history database after a successful snapshot, recording all issue-created events, publishing determinate import progress, marking the repository complete only after the frozen issue set is checkpointed, and leaving the current map usable throughout. +- [ ] Construct `HistoryStore` beside the existing JSON cache in runtime state. Start serialized low-priority history work only after successful current sync; use the issue metadata already returned by that sync. +- [ ] Make failed/offline creation import retain checkpoints and expose an incomplete diagnostic without making the current model stale solely because history is delayed. +- [ ] Run the focused server polling test target. + +### 1.5 Deliver authenticated history and scrub creation visibility locally + +- [ ] Add failing API tests for `GET /api/spaces/{id}/history?after=`: known-space validation, existing token/cookie/bearer protection, complete ordered events, delta sequence, compact summary, no path/raw-provider leakage, and `404` for an unknown space. +- [ ] Add the protected route and include only `HistorySummary` in whole-model snapshots. +- [ ] Add failing frontend tests for fetching once after completion, retaining events in memory, projecting issues whose creation is at or before the selected instant, defaulting to Now, and making repeated slider input without any network call. +- [ ] Implement `projectTemporalSpace(current, events, playhead)` as a pure adapter. Keep every current star in a structural renderer input and mark future stars hidden through a temporal overlay so creation-time scrubbing does not change layout signatures. +- [ ] Add the initial bottom-floating date/range control, disabled progress text while incomplete, `No issue history` for an empty ledger, Return to Now behavior, and reserved map inset. +- [ ] Assert with the renderer’s public `positions()`, `camera()`, and selection callbacks that scrubbing creation events changes visibility only. +- [ ] Run `npm.exe --prefix web test -- history.test.ts TemporalTimeline.test.ts App.test.ts StarMap.test.ts starmap/starmap.test.ts`. +- [ ] Update `CHANGELOG.md`, review `git diff 08bbaa8...HEAD`, and commit `feat(history): scrub issue creation from local ledger` with `Closes #78`. + +--- + +## Task 2: Issue #79 — Close and reopen history + +**Files:** + +- Modify: `crates/core/src/history.rs` +- Modify: `crates/core/tests/model_contract_test.rs` +- Create: `crates/github/src/history.rs` +- Modify: `crates/github/src/lib.rs` +- Modify: `crates/github/src/sync.rs` +- Create: `crates/github/tests/history_test.rs` +- Modify: `crates/history/src/store.rs` +- Modify: `crates/history/tests/store_test.rs` +- Modify: `crates/server/src/poll.rs` +- Modify: `crates/server/tests/api_test.rs` +- Modify: `web/src/lib/history.ts` +- Modify: `web/src/lib/history.test.ts` +- Modify: `web/src/lib/starmap/model.ts` +- Modify: `web/src/lib/starmap/adapt.ts` +- Modify: `web/src/lib/starmap/theme.ts` +- Modify: `web/src/lib/starmap/starmap.test.ts` +- Modify: `CHANGELOG.md` + +### 2.1 Retrieve only lifecycle history per issue + +- [ ] Add failing WireMock tests for a bounded, cursor-paginated single-issue timeline query that selects `ClosedEvent` and `ReopenedEvent`, retains provider event IDs/timestamps, ignores unrelated items, and surfaces malformed/ambiguous pages with issue/cursor/stage context. +- [ ] Implement `TemporalHistorySource` behavior on `GithubProvider` using the existing GraphQL client and typed error path. Return rate-limit/retry evidence without unbounded retrying. +- [ ] Run `cargo.exe test -p stellr-github --locked --test history_test`. + +### 2.2 Checkpoint and replay lifecycle transitions + +- [ ] Add failing ledger/import tests for same-page and repeated-page idempotence, atomic cursor advancement, restart resume, stable same-timestamp ordering, and no storage of untracked events. +- [ ] Extend the initial importer to retrieve one issue page stream at a time after recording creation, checkpoint each page, yield between requests, and complete only when all frozen issues share a verified boundary. +- [ ] Add failing pure frontend projection cases for exact close/reopen boundaries, repeated cycles, same-timestamp provider-ID ordering, and historical neutral-open/closed styles. +- [ ] Implement historical `open` versus `resolved` rendering without leaking current blocked/frontier/claimed/assignee status. Restore the full live status only at Now. +- [ ] Assert positions, camera, selected issue, and open detail remain unchanged across lifecycle frames. +- [ ] Run focused history, server, and frontend tests. +- [ ] Update `CHANGELOG.md`, self-review the issue delta, and commit `feat(history): replay issue close and reopen activity` with `Closes #79` and `Refs #78`. + +--- + +## Task 3: Issue #81 — Milestone transition history without movement + +**Files:** + +- Modify: `crates/core/src/history.rs` +- Modify: `crates/github/src/history.rs` +- Modify: `crates/github/tests/history_test.rs` +- Modify: `crates/history/src/store.rs` +- Modify: `crates/history/tests/store_test.rs` +- Modify: `web/src/lib/history.ts` +- Modify: `web/src/lib/history.test.ts` +- Modify: `web/src/lib/starmap/model.ts` +- Modify: `web/src/lib/starmap/starmap.ts` +- Modify: `web/src/lib/starmap/starmap.test.ts` +- Modify: `web/src/lib/StarMap.svelte` +- Modify: `web/src/lib/StarMap.test.ts` +- Modify: `CHANGELOG.md` + +### 3.1 Normalize milestone assignments, moves, and removals + +- [ ] Add failing provider tests for `MilestonedEvent` and `DemilestonedEvent`, stable milestone identity/title payloads, move ordering, removal to none, and filtering milestone rename noise. +- [ ] Extend domain and ledger payloads with previous/result milestone identities and display titles while preserving only minimal values. +- [ ] Run focused core, provider, and ledger tests. + +### 3.2 Render temporal milestone membership as an overlay + +- [ ] Add failing pure projection tests for creation-with-milestone, assignment, move, removal, and exact timestamp boundaries. +- [ ] Extend renderer tickets with current temporal milestone membership and visibility metadata, but keep `structureSignature` and `computeLayout` dependent only on the full present-day issue/edge/parent topology. +- [ ] Add a renderer overlay for milestone membership/hulls that can reshape around fixed star coordinates. Treat absent/no-milestone consistently and escape all titles as text. +- [ ] Add wrapper tests proving milestone changes update the renderer model without selection echo, camera changes, or world-coordinate changes. +- [ ] Run focused frontend tests. +- [ ] Update `CHANGELOG.md`, self-review the issue delta, and commit `feat(history): replay milestone membership without moving stars` with `Closes #81` and `Refs #79`. + +--- + +## Task 4: Issue #82 — Full-history playback, ticks, and speed control + +**Files:** + +- Modify: `web/src/lib/history.ts` +- Modify: `web/src/lib/history.test.ts` +- Create: `web/src/lib/playback.ts` +- Create: `web/src/lib/playback.test.ts` +- Modify: `web/src/lib/TemporalTimeline.svelte` +- Modify: `web/src/lib/TemporalTimeline.test.ts` +- Modify: `web/src/lib/StarMap.svelte` +- Modify: `web/src/lib/StarMap.test.ts` +- Modify: `web/src/App.svelte` +- Modify: `web/src/App.test.ts` +- Modify: `CHANGELOG.md` + +### 4.1 Implement a deterministic playback clock + +- [ ] Add failing fake-clock tests mapping the complete verified history to exactly 30 seconds at 1x, continuing from the current playhead, restarting at the first event when Play is pressed at Now, pausing at Now, and applying every crossed event in stable order after a slow frame. +- [ ] Implement a pure playback controller for 0.5x, 1x, 2x, and 4x. Keep the playhead in absolute provider time and never query during animation. +- [ ] Run `npm.exe --prefix web test -- playback.test.ts`. + +### 4.2 Add accessible event ticks and controls + +- [ ] Add failing component tests for control order (`date | slider/ticks | Play/Pause | speed`), proportional event positions, same-timestamp clustering, dense pixel clustering, tooltip event ordering, click/focus navigation, Left/Right distinct-event navigation, Home/End, play/pause, and speed cycling. +- [ ] Implement Play/Pause to the right of the slider and a visible/focusable speed button. At narrow widths move the date above the slider and retain a usable touch target. +- [ ] Add reduced-motion-aware event pulses and bounded captions for affected stars; group bursts without panning, changing selection, or opening detail. +- [ ] Assert playback does not change renderer coordinates/camera/selection and that current dependency edges appear only between temporally visible endpoints with subdued contextual styling. +- [ ] Run focused timeline, App, wrapper, and renderer tests. +- [ ] Update `CHANGELOG.md`, self-review the issue delta, and commit `feat(history): play complete history with event ticks` with `Closes #82` and `Refs #81`. + +--- + +## Task 5: Issue #83 — Delta-only background synchronization + +**Files:** + +- Modify: `crates/core/src/history.rs` +- Modify: `crates/github/src/history.rs` +- Modify: `crates/github/tests/history_test.rs` +- Modify: `crates/history/src/store.rs` +- Modify: `crates/history/tests/store_test.rs` +- Modify: `crates/server/src/poll.rs` +- Modify: `crates/server/tests/api_test.rs` +- Modify: `web/src/lib/history-api.ts` +- Modify: `web/src/lib/history.ts` +- Modify: `web/src/lib/history.test.ts` +- Modify: `web/src/lib/TemporalTimeline.svelte` +- Modify: `web/src/lib/TemporalTimeline.test.ts` +- Modify: `web/src/App.svelte` +- Modify: `web/src/App.test.ts` +- Modify: `CHANGELOG.md` + +### 5.1 Detect only relevant changed issues + +- [ ] Add failing importer/provider tests proving unchanged `updatedAt` values cause zero timeline requests, new issues get one targeted import, changed issues resume after their saved cursor, and a close/reopen cycle between ordinary polls is discovered. +- [ ] Implement post-completion synchronization from the metadata already piggybacked on ordinary current snapshots. Serialize history requests to one page stream, respect provider reset/retry evidence, and checkpoint conservative resume times. +- [ ] Add catch-up verification after an initial frozen import before declaring the ledger complete. +- [ ] Prove completed pages/issues/repositories are not broadly refetched after restart, reconnect, playback, or ordinary unchanged polls. +- [ ] Run focused provider, ledger, and server tests. + +### 5.2 Deliver and merge local deltas + +- [ ] Add failing API/frontend tests for `after=` returning only later events plus the new summary, client-side de-duplication by sequence/event ID, and no loss on reconnect. +- [ ] At Now, merge a verified delta and advance the live projection automatically. In the past, keep the playhead pinned and show an accessible `New activity` action that returns to Now. +- [ ] Make ordinary current-refresh errors preserve local history and never prune it. +- [ ] Run focused API and frontend tests. +- [ ] Update `CHANGELOG.md`, self-review the issue delta, and commit `feat(history): follow activity with delta-only synchronization` with `Closes #83` and `Refs #82`. + +--- + +## Task 6: Issue #84 — Offline, delayed, accessible native hardening + +**Files:** + +- Modify: `crates/history/src/store.rs` +- Modify: `crates/history/tests/store_test.rs` +- Modify: `crates/server/src/poll.rs` +- Modify: `crates/server/src/routes.rs` +- Modify: `crates/server/tests/api_test.rs` +- Modify: `crates/app/src/runtime.rs` +- Modify: `crates/app/tests/runtime_test.rs` +- Modify: `web/src/lib/TemporalTimeline.svelte` +- Modify: `web/src/lib/TemporalTimeline.test.ts` +- Modify: `web/src/App.svelte` +- Modify: `web/src/App.test.ts` +- Modify: `CHANGELOG.md` + +### 6.1 Harden storage, failure, and offline semantics + +- [ ] Add failing tests for migration rollback preserving the prior usable ledger, offline resume from the last page checkpoint, complete-ledger playback with an unreachable provider, delayed incremental history capped at `History through DATE`, explicit retry, rate-limit reset display, space-removal history deletion, and refresh errors preserving history. +- [ ] Implement diagnostic state transitions without inventing missing intervals. Return to Now must always restore the current snapshot even when verified history is delayed. +- [ ] Validate requested repository IDs against known spaces and keep all SQLite statements bound. +- [ ] Run focused Rust tests. + +### 6.2 Complete accessibility and responsive behavior + +- [ ] Add failing component tests for readable date/value text, persistent accessible names, visible focus affordances, focusable tick tooltips, keyboard navigation, status distinctions not conveyed by color alone, reduced-motion behavior, narrow layout, and sanitized event/milestone strings. +- [ ] Implement the remaining accessibility/responsive states and ensure the overlay reserves bottom inset without changing graph world coordinates. +- [ ] Run focused frontend tests. + +### 6.3 Native Windows acceptance and final verification + +- [ ] Build `web/dist` before Rust embedding: `npm.exe --prefix web run build`. +- [ ] Run `npm.exe --prefix web run check`. +- [ ] Run `npm.exe --prefix web test` once for the final frontend suite. +- [ ] Run `cargo.exe fmt --all -- --check`. +- [ ] Run `cargo.exe clippy --workspace --all-targets --locked -- -D warnings`. +- [ ] Run `cargo.exe test --workspace --locked` once for the final Rust suite. +- [ ] Run `cargo.exe build --workspace --locked`. +- [ ] Exercise native runtime acceptance against a temporary history root: complete import, scrub all four event kinds, play at all speeds, restart offline, resume an interrupted import, and verify request counts show no playback calls or completed repository-wide refetch. +- [ ] Verify the detail pane, selected issue, camera pose, and renderer `positions()` remain stable throughout a complete replay. +- [ ] Update `CHANGELOG.md` under `Unreleased` only. +- [ ] Run the required two-axis code review against fixed point `08bbaa8`: repository standards and the six issue/spec contracts. Fix every valid finding and rerun affected gates. +- [ ] Commit `feat(history): harden native temporal playback` with `Closes #84` and `Refs #83`. +- [ ] Confirm `git status --short --branch` is clean and report the branch/commit chain without pushing, opening a PR, or closing tracker items unless separately authorized. diff --git a/docs/superpowers/plans/2026-08-06-node-visual-hierarchy-2-5d.md b/docs/superpowers/plans/2026-08-06-node-visual-hierarchy-2-5d.md new file mode 100644 index 0000000..0438853 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-node-visual-hierarchy-2-5d.md @@ -0,0 +1,518 @@ +# Node Visual Hierarchy and 2.5D Rendering 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 in-progress work visually dominant, reduce ring and arrow clutter, and render stable 2.5D sphere nodes without changing graph topology or direct-only selection behavior. + +**Architecture:** Derive a narrow `WorkPriority` at the renderer adapter boundary, then let the Canvas 2D island use it for palette, metrics, rings, and label order. Keep depth entirely in paint layers and preserve PR #87's selected-last incident-edge pass while shrinking all arrowheads. + +**Tech Stack:** Svelte 5, TypeScript 6, Canvas 2D, Vitest 4, Rust/Tauri, native Windows PowerShell. + +## Global Constraints + +- Use native Windows executables only; do not use WSL or Linux tooling. +- Work only in `D:\tmp\stellr-selected-node-edge-emphasis` on `codex/node-visual-hierarchy-2-5d`; preserve the dirty primary checkout. +- Terminal issue states override stale workflow labels. +- Open priority is `in-progress`, assigned fallback, `ready-for-agent`, ordinary frontier, then blocked. +- The canonical ready label is exactly `ready-for-agent`; do not add a `ready` alias. +- Keep deterministic 2D coordinates, camera, historical topology, URLs, persistence, and server endpoints unchanged. +- Do not add WebGL, Three.js, Z coordinates, orbit controls, auto-rotation, or force simulation. +- Show at most one status ring plus one selection ring. +- Blocked, completed, out-of-scope, and ordinary frontier nodes have no outer status ring. +- All arrowheads use length `8` and half-width `4`; selected strokes retain the `1.7` multiplier. +- Selected emphasis stays limited to direct incoming and outgoing incident edges. +- Do not automatically relabel existing issues. + +--- + +### Task 1: Derive Workflow Priority at the Adapter Boundary + +**Files:** +- Create: `web/src/lib/starmap/work-priority.ts` +- Create: `web/src/lib/starmap/work-priority.test.ts` +- Modify: `web/src/lib/starmap/model.ts` +- Modify: `web/src/lib/starmap/adapt.ts` +- Modify: `web/src/lib/starmap/adapt.test.ts` + +**Interfaces:** +- Consumes: `Status`, issue labels, and assignees from `SpaceModel`. +- Produces: `WorkPriority = 'in_progress' | 'ready' | 'frontier' | 'blocked' | 'terminal'`. +- Produces: `deriveWorkPriority(input: WorkPriorityInput): WorkPriority`. +- Produces: `ticketWorkPriority(ticket): WorkPriority` for existing renderer fixtures. +- Produces: optional `Ticket.workPriority?: WorkPriority`; production adapter output always sets it. + +- [ ] **Step 1: Write the failing priority table** + +Create `work-priority.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { deriveWorkPriority } from './work-priority' + +describe('deriveWorkPriority', () => { + it.each([ + ['resolved overrides stale labels', 'resolved', ['in-progress'], ['ada'], 'terminal'], + ['out of scope overrides stale labels', 'out_of_scope', ['ready-for-agent'], [], 'terminal'], + ['literal in progress outranks a blocker', 'blocked', ['IN-PROGRESS'], [], 'in_progress'], + ['claimed is the assigned fallback', 'claimed', [], ['ada'], 'in_progress'], + ['an assignee is the assigned fallback', 'frontier', [], ['ada'], 'in_progress'], + ['ready requires an unblocked frontier', 'frontier', ['READY-FOR-AGENT'], [], 'ready'], + ['ready cannot override blocked', 'blocked', ['ready-for-agent'], [], 'blocked'], + ['unlabelled unblocked work stays frontier', 'frontier', [], [], 'frontier'], + ] as const)('%s', (_name, status, labels, assignees, expected) => { + expect(deriveWorkPriority({ status, labels, assignees })).toBe(expected) + }) +}) +``` + +- [ ] **Step 2: Run red** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/work-priority.test.ts +``` + +Expected: FAIL because `work-priority.ts` does not exist. + +- [ ] **Step 3: Implement the pure priority contract** + +Create `work-priority.ts`: + +```ts +import type { Status } from '../model' +import type { Ticket } from './model' + +export type WorkPriority = 'in_progress' | 'ready' | 'frontier' | 'blocked' | 'terminal' + +export interface WorkPriorityInput { + status: Status + labels: readonly string[] + assignees: readonly string[] +} + +const hasLabel = (labels: readonly string[], expected: string): boolean => + labels.some((label) => label.toLowerCase() === expected) + +export function deriveWorkPriority(input: WorkPriorityInput): WorkPriority { + if (input.status === 'resolved' || input.status === 'out_of_scope') return 'terminal' + if (hasLabel(input.labels, 'in-progress')) return 'in_progress' + if (input.status === 'claimed' || input.assignees.length > 0) return 'in_progress' + if (input.status === 'frontier' && hasLabel(input.labels, 'ready-for-agent')) return 'ready' + return input.status === 'blocked' ? 'blocked' : 'frontier' +} + +export function ticketWorkPriority( + ticket: Pick, +): WorkPriority { + if (ticket.workPriority) return ticket.workPriority + if (ticket.status === 'resolved' || ticket.status === 'out_of_scope') return 'terminal' + if (ticket.status === 'claimed') return 'in_progress' + if (ticket.readyForAgent) return 'ready' + return ticket.frontier ? 'frontier' : 'blocked' +} +``` + +Add a type-only `WorkPriority` import and `workPriority?: WorkPriority` to `Ticket`. In `toRendererModel`, derive priority once, assign it, and set `readyForAgent` only when priority is `ready`. + +- [ ] **Step 4: Extend adapter assertions** + +Make `adapt.test.ts` prove literal in-progress, assigned fallback, terminal override, truthful ready, and blocked-with-ready-label behavior: + +```ts +expect(model.map((ticket) => ticket.workPriority)).toEqual([ + 'in_progress', 'terminal', 'in_progress', 'terminal', 'blocked', +]) +expect(model.map((ticket) => ticket.readyForAgent)).toEqual([ + false, false, false, false, false, +]) +``` + +Add a separate unblocked `ready-for-agent` fixture and expect `workPriority: 'ready'` and `readyForAgent: true`. + +- [ ] **Step 5: Run green** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/work-priority.test.ts src/lib/starmap/adapt.test.ts +``` + +Expected: both files pass. + +- [ ] **Step 6: Commit** + +```powershell +git add -- web/src/lib/starmap/work-priority.ts web/src/lib/starmap/work-priority.test.ts web/src/lib/starmap/model.ts web/src/lib/starmap/adapt.ts web/src/lib/starmap/adapt.test.ts +git commit -m "feat(web): derive workflow visual priority" +``` + +--- + +### Task 2: Apply Priority Metrics, Palette, and Label Ordering + +**Files:** +- Modify: `web/src/lib/starmap/theme.ts` +- Create: `web/src/lib/starmap/theme.test.ts` +- Modify: `web/src/lib/starmap/starmap.ts` +- Modify: `web/src/lib/starmap/starmap.test.ts` + +**Interfaces:** +- Consumes: `WorkPriority` and `ticketWorkPriority` from Task 1. +- Produces: `priorityStarStyle(vstate: VisualState, priority: WorkPriority): StarStyle`. +- Produces: `priorityLabelColor(vstate: VisualState, priority: WorkPriority): string`. +- Produces: private renderer `Node.priority: WorkPriority`. +- Preserves: `visualState(ticket)` and every public layout/camera API. + +- [ ] **Step 1: Write failing metric tests** + +Create `theme.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { priorityLabelColor, priorityStarStyle } from './theme' + +describe('priorityStarStyle', () => { + it('orders active work without changing semantic palettes', () => { + expect(priorityStarStyle('blocked', 'in_progress')).toMatchObject({ core: '#ffd873', r: 8.1, gr: 42 }) + expect(priorityStarStyle('frontier', 'ready')).toMatchObject({ core: '#8ad8ff', r: 7.2, gr: 34 }) + expect(priorityStarStyle('frontier', 'frontier')).toMatchObject({ core: '#8ad8ff', r: 6.2, gr: 28 }) + expect(priorityStarStyle('blocked', 'blocked')).toMatchObject({ core: '#e2c3c3', r: 4.5, gr: 20 }) + expect(priorityStarStyle('resolved', 'terminal')).toMatchObject({ core: '#b9d6c4', r: 5.4, gr: 24 }) + expect(priorityStarStyle('out_of_scope', 'terminal')).toMatchObject({ core: '#948da4', r: 4.5, gr: 18 }) + expect(priorityLabelColor('blocked', 'in_progress')).toBe('#ffe6a0') + expect(priorityLabelColor('frontier', 'ready')).toBe('#b3e5ff') + }) +}) +``` + +- [ ] **Step 2: Run red** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/theme.test.ts +``` + +Expected: FAIL because `priorityStarStyle` is absent. + +- [ ] **Step 3: Implement exact metrics** + +Add to `theme.ts`: + +```ts +export function priorityStarStyle(vstate: VisualState, priority: WorkPriority): StarStyle { + if (priority === 'in_progress') return { ...STAR.claimed, r: 8.1, gr: 42 } + if (priority === 'ready') return { ...STAR.frontier, r: 7.2, gr: 34 } + if (priority === 'frontier') return { ...STAR.frontier, r: 6.2, gr: 28 } + return STAR[vstate] +} + +export function priorityLabelColor(vstate: VisualState, priority: WorkPriority): string { + if (priority === 'in_progress') return LABEL.claimed + if (priority === 'ready' || priority === 'frontier') return LABEL.frontier + return LABEL[vstate] +} +``` + +- [ ] **Step 4: Ingest priority without changing structure** + +Add `priority` to private `Node`. Set it with `ticketWorkPriority(t)` in both model-ingestion paths. Treat priority changes as paint/ticker changes, but do not add priority to `structureSignature`, layout input, or camera logic. Replace direct style reads in radius, glow, body, and label color with the priority-aware style. + +- [ ] **Step 5: Write the failing label-order test** + +Add a crowded `starmap.test.ts` fixture whose issue numbers oppose desired priority. Select issue `5`, make issue `6` the distinct CURRENT node, and assign in-progress `40`, ready `30`, frontier `20`, and blocked `10`. Assert retained label order: + +```ts +expect(labels.map((label) => label.text.slice(0, 2))).toEqual(['05', '06', '40', '30', '20', '10']) +``` + +- [ ] **Step 6: Implement label ordering** + +Keep selected first and a distinct CURRENT node second. Then order `in_progress`, `ready`, `frontier`, `blocked`, resolved, and out-of-scope. Use issue number only as the final same-priority tie-breaker. + +- [ ] **Step 7: Run green** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/theme.test.ts src/lib/starmap/starmap.test.ts +``` + +Expected: both files pass and deterministic layout assertions remain green. + +- [ ] **Step 8: Commit** + +```powershell +git add -- web/src/lib/starmap/theme.ts web/src/lib/starmap/theme.test.ts web/src/lib/starmap/starmap.ts web/src/lib/starmap/starmap.test.ts +git commit -m "feat(web): prioritize active work in the map" +``` + +--- + +### Task 3: Render 2.5D Spheres and the Two-Ring Grammar + +**Files:** +- Modify: `web/src/lib/starmap/starmap.ts` +- Modify: `web/src/lib/starmap/core-visual.test.ts` +- Modify: `web/src/lib/StarMap.svelte` +- Modify: `web/src/lib/StarMap.test.ts` + +**Interfaces:** +- Consumes: `Node.priority` and `priorityStarStyle` from Task 2. +- Produces: `StarMap.setReducedMotion(reduced: boolean): void`. +- Preserves: selection, session overlays, label text, and Canvas 2D ownership. + +- [ ] **Step 1: Write failing sphere-layer tests** + +Extend the `core-visual.test.ts` recorder so gradients retain origin/edge coordinates and arcs retain center/radius. Assert paint order is semantic glow, offset contact shadow, semantic body gradient, small above-left specular gradient, then one internal boundary. + +For a ready body, assert exact body stops: + +```ts +expect(bodyGradient.stops).toEqual([ + { at: 0, color: 'rgba(138,216,255,1)' }, + { at: 0.48, color: 'rgba(138,216,255,0.98)' }, + { at: 0.82, color: 'rgba(47,155,224,0.92)' }, + { at: 1, color: 'rgba(47,155,224,0.62)' }, +]) +expect(shadow.arc!.y).toBeGreaterThan(body.arc!.y) +expect(specular.arc!.radius).toBeLessThan(body.arc!.radius / 3) +``` + +- [ ] **Step 2: Write failing ring-count tests** + +Add an `outerRings` helper that excludes the internal boundary by radius. Assert: + +```ts +expect(outerRings(paint(IN_PROGRESS))).toHaveLength(1) +expect(outerRings(paint(READY_CHILD))).toHaveLength(1) +expect(outerRings(paint(BLOCKED))).toHaveLength(0) +expect(outerRings(paint(RESOLVED))).toHaveLength(0) +expect(outerRings(paint({ ...BLOCKED, parentIssue: 99 }))).toHaveLength(0) +expect(outerRings(paint(READY_CHILD, null, READY_CHILD.num))).toHaveLength(2) +expect(outerRings(paint(BLOCKED, null, BLOCKED.num))).toHaveLength(1) +``` + +Extend `paint` with a `selectedIssue` argument that calls `map.select` after +`setModel`. Record a selected click frame and assert no expanding flare stroke +is painted. Keep separate coverage proving a distinct CURRENT node receives no +outer CURRENT ring and retains its label/path semantics. + +- [ ] **Step 3: Run red** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/core-visual.test.ts +``` + +Expected: FAIL against the black disk, subissue rim, paired claimed/CURRENT rings, and ring flare. + +- [ ] **Step 4: Implement sphere layers** + +Calculate `priorityStarStyle` once. Keep the semantic glow, then paint: + +- contact shadow centered at `y + cr * 0.72`, radius `cr * 1.08`, black alpha `0.26` to `0`; +- body gradient with an above-left origin and the exact stops from Step 1; +- specular centered at `x - cr * 0.32`, `y - cr * 0.34`, radius `cr * 0.28`, white alpha `0.46` to `0`; +- one internal boundary at `cr - lineWidth / 2`. + +Use `c.core`, `c.glow`, and `hexA` for the body stops: core at `1`, core at +`0.98`, glow at `0.92`, and glow at `0.62`. Do not add a new semantic hue. + +- [ ] **Step 5: Implement the ring grammar** + +Delete `SUBISSUE_RIM`, the subissue rim, both CURRENT strokes, and the flare stroke. Paint one breathing amber ring at `cr + 7 + beat` for `in_progress`, one steady cyan ring at `cr + 7` for `ready`, and one white selection ring at `cr + 13` when a status ring exists or `cr + 7` otherwise. Keep `flare` only as a short semantic-glow multiplier. + +- [ ] **Step 6: Add reduced-motion wiring** + +Add private `#reducedMotion = false` and: + +```ts +setReducedMotion(reduced: boolean): void { + this.#reducedMotion = reduced +} +``` + +Use midpoint beat `0.5` when reduced motion is true. Continue decrementing the +private flare timer, but draw it as `fl > 0 ? 1 : 0` under reduced motion so the +glow remains visually static and then clears instead of interpolating. Add two +recorded reduced-motion frames while the timer is positive and assert identical +glow geometry/alpha. In `StarMap.svelte`, subscribe to +`matchMedia('(prefers-reduced-motion: reduce)')`, set the initial value, forward +changes, and remove the listener during cleanup. In `StarMap.test.ts`, assert +initial and changed values reach the same renderer instance. + +- [ ] **Step 7: Run green** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/core-visual.test.ts src/lib/StarMap.test.ts src/lib/starmap/starmap.test.ts +``` + +Expected: all files pass with maximum-two-ring and reduced-motion coverage. + +- [ ] **Step 8: Commit** + +```powershell +git add -- web/src/lib/starmap/starmap.ts web/src/lib/starmap/core-visual.test.ts web/src/lib/StarMap.svelte web/src/lib/StarMap.test.ts +git commit -m "feat(web): render dimensional issue nodes" +``` + +--- + +### Task 4: Shrink Arrowheads Without Weakening Direct Selection + +**Files:** +- Modify: `web/src/lib/starmap/starmap.ts` +- Modify: `web/src/lib/starmap/edge-visual.test.ts` + +**Interfaces:** +- Consumes: the existing selected-edge incident predicate and selected-last pass. +- Produces: shared arrow geometry with length `8` and half-width `4`. +- Preserves: selected stroke scale `1.7`, semantic styling, motion, full selected opacity, and direct-only order. + +- [ ] **Step 1: Change arrow expectations first** + +Update every ordinary, selected dependency, and selected parent/subissue assertion: + +```ts +expectArrowDimensions(arrow, 8, 4) +``` + +Keep exact selected/non-selected stroke-order assertions and explicitly compare selected and context arrow dimensions for equality. + +- [ ] **Step 2: Run red** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/edge-visual.test.ts +``` + +Expected: FAIL because current ordinary arrows are `12` by `6.5` and selected arrows are `15` by `8.125`. + +- [ ] **Step 3: Implement compact shared geometry** + +Delete `SELECTED_EDGE_ARROW_SCALE` and use: + +```ts +const ah = 8 +const aw = 4 +``` + +Do not change stroke scaling, paint order, alpha, color, dashes, curves, tangent direction, particles, or `#isSelectedEdge`. + +- [ ] **Step 4: Run edge and node regressions** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/edge-visual.test.ts src/lib/starmap/core-visual.test.ts +``` + +Expected: both files pass; direct-only selection and maximum-two-ring assertions stay green. + +- [ ] **Step 5: Commit** + +```powershell +git add -- web/src/lib/starmap/starmap.ts web/src/lib/starmap/edge-visual.test.ts +git commit -m "feat(web): reduce graph arrowheads" +``` + +--- + +### Task 5: Document, Validate, and Add the Tracker Label + +**Files:** +- Modify: `CHANGELOG.md` +- Verify: every file changed since `origin/main` +- External mutation: create the approved `in-progress` label in `teloverge/stellr` + +**Interfaces:** +- Consumes: Tasks 1-4. +- Produces: newest-first Unreleased notes and the approved tracker label. +- Produces no issue relabeling, push, PR, merge, release, or installer. + +- [ ] **Step 1: Add the newest Unreleased entry** + +Insert directly under `## Unreleased`: + +```markdown +- Clarified active-work priority with dimensional issue nodes, a maximum of two + status/selection rings, compact arrows, and literal `in-progress` label + support ahead of `ready-for-agent` and blocked work. +``` + +- [ ] **Step 2: Run frontend verification** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web run check +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web run build +``` + +Expected: all Vitest files pass, Svelte reports `0 errors and 0 warnings`, and Vite exits `0`. + +- [ ] **Step 3: Run native workspace verification** + +```powershell +cargo.exe fmt --all -- --check +cargo.exe clippy --workspace --all-targets --locked -- -D warnings +cargo.exe test --workspace --locked -- --test-threads=1 +``` + +Expected: all commands exit `0`; only explicitly ignored tests remain unrun. + +- [ ] **Step 4: Verify scope and generated-file cleanliness** + +```powershell +git diff --check origin/main...HEAD +git status --short --branch +git diff --stat origin/main...HEAD +``` + +If Tauri changes only the nine known `crates/app/permissions/autogenerated/*.toml` files, first prove `git diff --ignore-space-at-eol --exit-code` for those exact paths, then restore only those files. Never restore a semantic diff. + +- [ ] **Step 5: Perform native visual verification** + +Build and launch the updated native desktop binary from the isolated worktree: + +```powershell +cargo.exe build --package stellr-app --release --bin stellr-desktop +Start-Process -FilePath target\release\stellr-desktop.exe -WorkingDirectory target\release +``` + +Inspect the live map at ordinary and selected zoom levels. Confirm an assigned +or `in-progress` issue is the strongest sphere with one amber ring, a +`ready-for-agent` issue has one quieter cyan ring, blocked and completed nodes +have no outer status ring, selection adds exactly one white ring, nodes retain +their positions, and arrowheads remain readable at the compact size. Capture +the selected-ready and selected-in-progress states when live data provides +them; otherwise use the focused canvas recordings as the deterministic evidence +for those combinations and state that limitation explicitly. + +- [ ] **Step 6: Commit changelog after gates pass** + +```powershell +git add -- CHANGELOG.md +git commit -m "docs: record node visual hierarchy" +``` + +- [ ] **Step 7: Create and verify the approved tracker label** + +Recheck that it is absent: + +```powershell +gh label list --repo teloverge/stellr --limit 100 --json name --jq ".[].name" +``` + +If absent, create exactly: + +```powershell +gh label create in-progress --repo teloverge/stellr --color fbca04 --description "Work is actively being implemented" +``` + +Verify without applying it to any issue: + +```powershell +gh label list --repo teloverge/stellr --search in-progress --json name,color,description +``` + +Expected: one `in-progress` label, color `fbca04`, with the approved description. + +- [ ] **Step 8: Run final committed-state verification** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test +git status --short --branch +git log --oneline origin/main..HEAD +``` + +Expected: frontend tests pass, the worktree is clean, and the branch contains only the design, plan, implementation, and changelog commits. diff --git a/docs/superpowers/specs/2026-08-04-temporal-history-slider-design.md b/docs/superpowers/specs/2026-08-04-temporal-history-slider-design.md new file mode 100644 index 0000000..dceccc9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-temporal-history-slider-design.md @@ -0,0 +1,434 @@ +# Temporal Issue-History Slider Design + +**Date:** 2026-08-04 +**Status:** Approved in design review +**Scope:** The interactive Stellr issue map in desktop and `serve` modes + +## Purpose + +Add a temporal control to the bottom of the Stellr map so a user can inspect +and replay the repository's issue history. The control uses real GitHub issue +dates, marks recorded events, and projects the map as of the selected time. + +The feature must minimize GitHub retrieval. Historical data is imported once, +stored locally, resumed rather than restarted after interruption, and updated +with targeted delta requests. Scrubbing and playback never query GitHub. + +## User Experience Contract + +The timeline is a floating control attached to the bottom of the map region. +It does not span the sidebar or cover the issue-detail pane. When issue detail +is bottom-docked, the map becomes shorter and the timeline remains attached to +the map's new bottom edge. + +The control is ordered left to right as: + +```text +selected date | chronological slider with event ticks | Play/Pause | speed +``` + +The selected range begins at the repository's first issue creation and ends at +the latest fully verified history checkpoint. When that checkpoint is current, +the right endpoint is labelled **Now**. + +The default view is Now. While the playhead remains at Now, a newly verified +event extends the range and updates the live map automatically. If the user is +viewing the past, new activity does not move the playhead. The control instead +shows **New activity · Return to Now**. + +## Tracked Activity + +Only these event kinds participate in the temporal projection and produce +ticks: + +- issue created; +- issue closed; +- issue reopened; +- milestone changed, including assignment to a milestone, movement between + milestones, and removal from a milestone. + +Comments, title and body edits, label changes, assignments, dependency edits, +and sub-issue edits are not timeline events in this feature. + +The importer may use an issue's provider `updatedAt` value as a cheap signal +that its timeline needs examination. Events outside the tracked set are +discarded during normalization and never enter the local event ledger. + +## Historical Projection + +The renderer uses one deterministic coordinate set for the entire history. +Coordinates are derived from the complete current issue set and current graph +topology, not from the selected time. Moving the playhead never recomputes star +positions or moves the camera. + +At a selected instant: + +- an issue whose creation is later than the playhead is absent; +- an issue that exists and is open uses a distinct neutral + **open-at-this-time** treatment; +- a closed issue uses the existing resolved treatment; +- a reopened issue returns to the neutral historical-open treatment; +- an issue belongs to the milestone recorded for it at that instant; +- milestone hulls may change membership and shape, but stars do not move. + +Historical mode deliberately does not display blocked, frontier, or claimed +status. Those states require assignment and dependency history, which is +outside the selected event scope. At Now, Stellr restores its complete live +status derivation. + +Dependency edges are current-topology context rather than reconstructed +history. In historical mode they remain spatially fixed, are limited to edges +whose two endpoint stars exist at the selected instant, and use subdued styling +with a **Current dependencies** legend. This makes their temporal limitation +explicit instead of implying that Stellr reconstructed past dependency edits. + +Temporal playback changes only the map projection. It never changes the +canonical route, selected issue, open detail pane, or camera. A selected star +may be absent before its creation time; the selection remains available when +the playhead returns to a time at which the star exists. + +## Playback + +Playback covers the complete chronological range in a fixed presentation time, +not in real elapsed repository time: + +| Speed | Full-history duration | +| --- | ---: | +| 0.5x | 60 seconds | +| 1x | 30 seconds | +| 2x | 15 seconds | +| 4x | 7.5 seconds | + +Calendar time remains linearly proportional to slider position. Long quiet +periods are therefore represented honestly even when no visible event occurs. + +Play continues from the current playhead. Pressing Play while already at Now +restarts at the first event. Reaching Now pauses. Dragging the slider pauses +playback and updates the map continuously. + +Playback uses a monotonic animation clock. It converts elapsed presentation +time into an absolute event time, then applies all newly crossed events in +stable order. A slow frame cannot skip state. Events sharing a timestamp are +applied as one atomic group. + +Each reached event briefly pulses its affected star and displays a short +caption such as `#42 created`, `#18 reopened`, or `#7 moved to M2`. A group of +events produces one bounded summary. Event feedback never pans the camera, +opens detail, or changes selection. Under reduced motion, the state and caption +still update but the pulse is omitted. + +## Tick Interaction + +Tick position is proportional to the event's actual timestamp. Events with the +same timestamp form one logical tick. Ticks that overlap at the current rendered +width are grouped into a visual cluster without changing the underlying time or +event order. + +Hovering or keyboard-focusing a tick or cluster shows the exact local date and +the contained event summaries. Clicking moves the playhead to that instant. A +cluster tooltip lists its events in timestamp and provider-event-ID order. + +The slider is a semantic range control with readable date `aria-valuetext`. +Keyboard behavior is: + +- Left/Right: previous or next distinct event time; +- Home: first event; +- End: latest verified time; +- Space/Enter on Play: play or pause; +- Space/Enter on speed: cycle 0.5x, 1x, 2x, and 4x. + +## Architecture + +The current snapshot and temporal history remain separate responsibilities. +The existing `Provider` continues to supply the present issue graph. A new +`TemporalHistorySource` supplies normalized historical evidence. The live app +may reuse GitHub GraphQL transport, error mapping, and event normalization +patterns from `crates/showcase`, but it does not depend on release windows, +story beats, asset generation, or showcase manifests. + +```text +existing GitHub issue snapshot + | + +------> current Model and live renderer + | + +------> relevant-change detector + | +GitHub historical import --> TemporalHistorySource + | + v + local SQLite ledger + | + history status + delta API + | + v + frontend temporal projection + | + v + existing canvas renderer +``` + +### Domain events + +The normalized domain event contains: + +- stable provider repository identity; +- stable provider issue identity and display number; +- stable provider event identity; +- exact UTC occurrence timestamp; +- one of `issue_created`, `issue_closed`, `issue_reopened`, or + `milestone_changed`; +- the minimum payload needed to apply that transition. + +An issue-creation event records its milestone at creation, if any. A milestone +transition records stable milestone identity plus the prior and resulting +display titles when the provider exposes them. Renaming a milestone is not a +tracked activity and does not create a tick. + +Issue creation does not necessarily have a GitHub timeline-item ID. In that +case its provider event ID is derived deterministically from the provider issue +node ID and the `created` event kind. Provider-supplied timeline IDs are used +unchanged for all other events. + +Events are ordered by `(occurred_at, provider_event_id)`. This order is stable +across pagination, retries, process restarts, and input ordering. + +### Local ledger + +Stellr adds a SQLite history database alongside, not inside, the existing JSON +current-snapshot cache. The ledger stores: + +- provider repositories and stable provider repository IDs; +- provider issues and stable issue IDs/numbers; +- normalized events with a uniqueness constraint on repository and provider + event ID; +- a monotonically increasing local ledger sequence for delta delivery; +- per-issue pagination cursors and last-seen provider `updatedAt` values; +- repository import state, counts, retry information, and verified-through + watermark; +- a schema version managed by transactional migrations. + +Event insertion, cursor advancement, and import-progress advancement occur in +one transaction. A crash can repeat the last provider page, but uniqueness and +the transaction boundary make the retry idempotent. + +Tokens, API headers, comments, issue bodies, unrelated timeline items, and UI +playhead state are not stored in the history database. + +### Initial background import + +The first successful current sync starts an automatic, low-priority history +import. The importer freezes a provider cutoff, enumerates the issues known at +that cutoff, and retrieves only the selected timeline item kinds for each +issue. It processes one bounded page at a time, checkpoints every accepted +page, and yields between requests. + +New activity may occur during the import. After all enumerated issue histories +reach the frozen cutoff, the importer performs a targeted catch-up against a +fresh current snapshot. The ledger becomes complete only when every initial +issue and every relevant catch-up issue is verified through the same successful +sync boundary. + +The current map remains fully usable during import. The timeline stays visible +but disabled and reports determinate progress such as +`Building history · 312/840 issues`. No partial ticks are exposed. + +### Incremental synchronization + +The existing issue-list request gains only the stable provider IDs, +`createdAt`, and `updatedAt` fields needed by history synchronization. This +piggybacks on ordinary snapshot pagination rather than adding another +repository-wide query. + +After initial completion: + +1. a new provider issue creates a targeted import for that issue; +2. an unchanged issue `updatedAt` causes no history request; +3. a changed `updatedAt` queues only that issue's timeline after its saved + cursor; +4. normalization discards non-tracked events; +5. accepted events advance the verified history and ledger revision. + +Using `updatedAt` rather than only comparing current state is intentional. It +allows Stellr to discover a close/reopen cycle that begins and ends between two +ordinary polls. It can cause a small targeted timeline request after an +untracked edit or comment, but it avoids both a full-history refetch and a +false claim of complete history. + +## Retrieval and Rate-Limit Policy + +History work is always lower priority than the live snapshot. It has bounded +concurrency of one request/page stream per application process and never uses +unbounded retries. + +The importer reads GitHub rate-limit cost, remaining budget, and reset evidence +available through the existing typed provider error path. It pauses before +exhaustion, records the resume time, and resumes after that time or an explicit +manual retry. Abuse-detection or secondary-rate-limit responses use the +provider's retry evidence when present and conservative backoff otherwise. + +Completed pages, issues, and repositories are never fetched again unless the +provider reports a new `updatedAt` value or the user explicitly clears local +history. Playback, scrubbing, tick tooltips, application restarts, and offline +use make no GitHub requests. + +## Server and Frontend Data Flow + +The whole-model control WebSocket remains the current-state authority. Each +space gains only a compact history summary: + +- import state; +- completed and total issue counts; +- earliest event time; +- verified-through time; +- local ledger revision; +- delayed, rate-limited, or failed diagnostic when applicable. + +A dedicated authenticated loopback endpoint returns the complete normalized +history after initial completion. Subsequent requests accept the last local +ledger sequence and return only later events plus the new history summary. The +frontend retains the event array in memory and derives historical projections +without server round trips while the user scrubs or plays. + +The history endpoint follows the existing per-run bearer-token protection in +desktop mode and the existing authenticated server policy in `serve` mode. It +never exposes the SQLite path or raw provider responses. + +## Delayed, Offline, and Failure States + +- **Initial import incomplete:** timeline disabled; current map unaffected. +- **Offline during initial import:** checkpoint retained; import resumes when + connectivity returns. +- **Complete ledger offline:** verified history remains playable. +- **Incremental history delayed:** playback ends at the last verified + checkpoint and displays `History through DATE`; Return to Now exits temporal + mode and shows the current snapshot without inventing the missing interval. +- **Rate limited:** import status shows the reset/resume time when known. +- **Malformed or ambiguous provider history:** the affected repository import + fails with issue number, event identity, cursor, and stage where available. +- **Migration failure:** the migration transaction rolls back and the previous + usable ledger remains intact. +- **Space removal:** the explicit existing space-removal workflow removes that + space's local history; ordinary refreshes and errors never prune it. +- **No issues:** the control displays `No issue history` and remains disabled. + +Stellr never fills an unverified interval from observation time, local wall +clock guesses, current blocker state, or synthetic lifecycle events other than +the deterministic issue-creation identity described above. + +## Accessibility and Responsive Behavior + +- The floating control meets the existing token-based contrast policy and does + not use status color as its only event distinction. +- The playhead exposes a readable date, not only a numeric epoch. +- Play/Pause and speed have persistent accessible names and visible focus. +- Tick tooltips are available through focus as well as hover. +- Reduced motion removes pulses and animated emphasis while preserving state, + captions, and manual navigation. +- At narrow map widths, the date moves above the slider and controls remain in + a compact second row; the slider never collapses below a usable touch target. +- The overlay reserves enough bottom inset that stars and labels can be panned + clear of it without changing the graph's world coordinates. + +## Privacy and Security + +- Historical acquisition remains read-only. +- No new GitHub permission scope is required. +- The history database excludes tokens, comments, bodies, response headers, + and unrelated timeline events. +- All event strings crossing into the UI are rendered as text. +- Database and endpoint repository identifiers are validated against known + spaces; callers cannot supply an arbitrary filesystem database path. +- SQLite statements use bound parameters, and migrations are fixed application + resources rather than runtime input. + +## Testing Strategy + +### Domain projection + +- Created, closed, reopened, and every milestone transition at exact boundary + timestamps. +- Same-timestamp ordering by provider event ID. +- Issues absent before creation and neutral-open after creation or reopening. +- Now restoring the live blocked/frontier/claimed derivation. +- Current dependency edges filtered to visible endpoints and identified as + current topology. +- Fixed star coordinates through every projection and milestone hull change. + +### Ledger and importer + +- Unique event IDs make repeated pages and catch-up retries idempotent. +- Cursor, events, progress, and watermark commit atomically. +- Restart resumes at the saved issue and cursor rather than restarting. +- Initial completeness requires frozen-cutoff import plus catch-up. +- Changed `updatedAt` queues one issue; unchanged metadata queues none. +- A close/reopen cycle between polls is retained even when final state matches + the prior snapshot. +- Non-tracked events are filtered and not stored. +- Transactional schema migration success and rollback. +- Explicit space removal deletes only the intended repository history. + +### Provider and retrieval policy + +- GraphQL pagination requests only the selected timeline kinds. +- Existing list pagination carries IDs and timestamps without a second list + request. +- Rate-limit exhaustion pauses until reset and does not busy-loop. +- Secondary-limit responses use bounded conservative backoff. +- Completed history is not fetched again on playback, reconnect, or restart. +- Offline and malformed-response diagnostics name the failing stage. + +### Server and frontend + +- History summary rides current snapshots without embedding the event ledger. +- Initial history response and ledger-sequence deltas are consistent. +- Authentication protects the history endpoint in desktop and serve modes. +- Thirty-second 1x playback and 0.5x, 2x, and 4x variants under a controllable + monotonic clock. +- Slow animation frames apply every crossed event in order. +- Dragging pauses; Play at Now restarts; reaching the end pauses. +- Default Now following, past-view pinning, new-activity notice, and Return to + Now. +- Dense tick clustering, exact tick navigation, captions, and grouped events. +- Playback preserves route, issue detail, camera, and selection. +- Keyboard, screen-reader, reduced-motion, and narrow-layout behavior. + +### Native acceptance + +- Native Windows Rust tests, frontend tests, type checks, formatting, linting, + and packaged desktop smoke tests. +- A repository with a complete ledger plays while GitHub is unreachable. +- A deliberately interrupted large import resumes without re-fetching accepted + pages. +- Provider request logs demonstrate no history request during repeated + playback and no repository-wide history refetch after completion. + +## Non-Goals + +- Historical comments, titles, bodies, labels, assignments, blockers, or + sub-issue relationships. +- Historically exact blocked/frontier/claimed status. +- Historical dependency-edge reconstruction. +- Timeline zoom, arbitrary loop ranges, bookmarks, or exported recordings. +- Cross-device history synchronization or a hosted history service. +- Replacing the current snapshot cache or the release-showcase story format. +- Automatically opening issue detail or moving the map camera during playback. + +## Acceptance Criteria + +- A fixed-bottom map control exposes date-proportional ticks, scrubbing, + Play/Pause, and 0.5x, 1x, 2x, and 4x playback. +- At 1x, the full verified history plays in 30 seconds. +- The map is spatially stable throughout playback. +- Temporal state covers only issue creation, close/reopen, and milestone + changes and does not imply unavailable historical status information. +- Event feedback highlights affected stars without changing route, selection, + detail, or camera. +- A complete local ledger is required before playback becomes available. +- Initial import is automatic, low priority, resumable, rate-limit aware, and + never blocks the live map. +- Completed history is never broadly refetched; incremental synchronization + targets only new or provider-updated issues. +- Scrubbing and playback are entirely local and work offline after completion. +- Unverified gaps remain explicit and are never filled with invented history. +- Storage, transport, projection, timing, accessibility, and native Windows + acceptance tests enforce the design. diff --git a/docs/superpowers/specs/2026-08-06-node-visual-hierarchy-2-5d-design.md b/docs/superpowers/specs/2026-08-06-node-visual-hierarchy-2-5d-design.md new file mode 100644 index 0000000..5a1b5f5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-node-visual-hierarchy-2-5d-design.md @@ -0,0 +1,204 @@ +# Node Visual Hierarchy and 2.5D Rendering Design + +## Goal + +Make the map's work priority readable at a glance, reduce ring and arrowhead +clutter, and give nodes an Obsidian-inspired three-dimensional presence without +replacing Stellr's deterministic two-dimensional topology or camera. + +The visual priority for active work is: + +1. `in-progress`; +2. `ready-for-agent`; +3. blocked. + +Closed and not-planned issues remain terminal states rather than active work. + +## Status Resolution + +Status derivation continues to resolve terminal issue state first. A closed +issue is completed and a closed-not-planned issue is out of scope even if stale +workflow labels remain on GitHub. + +For an open issue, resolve the visible priority in this order: + +1. a case-insensitive `in-progress` label; +2. the existing claimed fallback when the issue has one or more assignees; +3. the canonical case-insensitive `ready-for-agent` label when no blocker is + open; +4. blocked when at least one blocker remains open; +5. the existing unblocked frontier treatment without a READY marker. + +An open issue labeled `in-progress` therefore retains the strongest active +treatment even when it has an unresolved blocker. `ready-for-agent` remains +truthful only for unblocked work. Do not introduce a `ready` alias: the +repository's canonical tracker vocabulary remains `ready-for-agent`. + +Add the missing `in-progress` tracker label with color `#fbca04` and description +`Work is actively being implemented`. Do not automatically add or remove it on +existing issues; label application remains an explicit tracker action. + +## Reference Treatment + +Obsidian's core Graph View establishes the useful interaction vocabulary: +circles are nodes, lines are relationships, and focusing a node highlights its +connections. Community 3D graph views add lit sphere meshes, perspective, +orbiting, and neighbor emphasis. Stellr adopts only the lit-sphere depth cue and +neighbor emphasis from that treatment. It deliberately retains its fixed 2D +map, camera, and direct-edge behavior. + +References: + +- +- + +## Node Rendering + +Keep the current Canvas 2D renderer and deterministic node coordinates. Render +each node as a 2.5D sphere using layered radial gradients: + +- a small, soft specular highlight above and left of center; +- the semantic status color across the middle of the sphere; +- a darker lower-right falloff that supplies volume; +- a restrained contact shadow below the sphere; +- one thin internal boundary stroke that is part of the body, not an outer + status ring. + +The depth treatment must preserve the existing status palette. It may derive +lighter and darker variants from the semantic color, but it must not introduce +a new color meaning. The highlight and shadow stay fixed in screen orientation +so nodes read as consistently lit while the map pans and zooms. + +This is visual depth, not graph depth. Do not add Z coordinates, perspective +layout, orbit controls, auto-rotation, WebGL, Three.js, force simulation, or +status-dependent node movement. Historical replay must keep the same spatial +topology. + +## Ring Grammar + +An outer ring has exactly one meaning. Stable rendering may show at most one +status ring and one selection ring around a node. + +- `in-progress`: one amber breathing status ring. This is the strongest active + treatment. Replace the current pair of claimed rings with this single ring. +- `ready-for-agent`: one quieter cyan status ring with no breathing motion. +- blocked, completed, out-of-scope, and ordinary unblocked frontier: no outer + status ring; render only the 2.5D node body. +- selected: one neutral white selection ring outside the status ring when one + exists, or outside the node body otherwise. + +Remove the node-level parent/subissue rim. Parent and subissue meaning already +travels through the workflow edges and must not consume another ring channel. +Remove the two CURRENT rings because CURRENT already has a label and path +emphasis, while selection alone owns the neutral white ring. CURRENT and +selection may name different nodes; removing CURRENT's rings must not merge or +otherwise change those states. Replace the ring-shaped click flare with a brief +increase in sphere glow so interaction feedback cannot create a third outer +ring. + +The maximum stable count is therefore two outer rings for a selected +in-progress or ready issue, one for an unselected in-progress or ready issue, +one for a selected blocked or completed issue, and zero for an unselected +blocked or completed issue. + +## Relative Emphasis and Labels + +The base node sizing, glow strength, and label-slot priority must follow the +same active-work order. Use these body-radius and glow-radius pairs before the +existing issue-radius scale is applied: + +- in-progress: `8.1` and `42`; +- ready-for-agent: `7.2` and `34`; +- ordinary unblocked frontier: `6.2` and `28`; +- blocked: `4.5` and `20`; +- completed: retain `5.4` and `24`; +- out-of-scope: retain `4.5` and `18`. + +Selection remains the first label-placement priority because it reflects the +operator's immediate action. A distinct CURRENT node remains second because it +anchors the active session. After those two interaction states, an in-progress +issue gets a contested label slot before a ready issue, a ready issue gets it +before an ordinary frontier issue, and an ordinary frontier issue gets it +before a blocked issue. Completed and out-of-scope nodes retain their existing +relative label priority after active work. + +Keep the existing selected label and detail-pane behavior. A selected ready +issue may still say `CURRENT / READY`; this design changes the surrounding +visual clutter, not the label vocabulary. + +## Edges and Arrowheads + +Preserve every edge's semantic color, dash pattern, direction, particle motion, +direct-only selection membership, and selected-last paint order from PR #87. + +Reduce ordinary arrowhead length from `12` to `8` canvas units and half-width +from `6.5` to `4`. Selected edges use the same compact arrowhead dimensions; +selection continues to read through full opacity, painter order, and the +existing `1.7` stroke-width multiplier. Remove the selected arrow-size +multiplier so selection does not make arrowheads busy again. + +Do not change edge paths, curve geometry, particle size, topology, or the rule +that only edges directly incident to the selected node receive selected +emphasis. + +## Renderer and Model Boundaries + +Keep sphere paint, ring paint, click glow, and arrowhead dimensions inside the +imperative canvas renderer. Isolate workflow-label precedence in a small, +purely testable derivation function rather than scattering label checks through +paint code. + +The pushed model already carries labels and assignees, but the renderer adapter +currently retains only `readyForAgent`. Extend the narrow renderer ticket model +with the minimum derived workflow flags needed by the renderer. Do not pass the +entire label array into the canvas island or change layout signatures, camera +state, URLs, persistence, server endpoints, or historical event storage. + +## Accessibility and Motion + +Color is not the only signal: + +- in-progress has a breathing ring and stronger size/glow; +- ready has one steady ring; +- blocked and terminal nodes have no outer status ring; +- selection has a neutral outer ring and direct-edge emphasis. + +Under reduced motion, freeze the in-progress ring at its midpoint and keep the +click response as a short static glow change. The sphere highlight and shadow +remain visible without animation. + +## Verification + +Add or update tests that prove: + +- terminal states override stale `in-progress` and `ready-for-agent` labels; +- open-state precedence is in-progress, then assigned fallback, then ready, + then blocked/frontier; +- a blocked issue cannot become ready solely because it has the ready label; +- in-progress and ready nodes have exactly one status ring; +- blocked and completed nodes have no outer status ring; +- selection adds exactly one ring and never produces more than two stable outer + rings; +- parent/subissue membership adds no node ring; +- click feedback changes glow without adding a ring; +- sphere painting records the highlight, semantic body, shade, shadow, and body + boundary in the intended order; +- label priority orders selected first, a distinct CURRENT node second, then + in-progress, ready, ordinary frontier, and blocked; +- ordinary and selected arrowheads both use length `8` and half-width `4`; +- selected edges retain `1.7` stroke scaling, full-opacity selected-last paint, + semantic styling, and direct-only scope; +- reduced motion freezes rather than removes the in-progress status signal. + +The completion gate is the focused derivation and canvas tests, complete +frontend tests, Svelte check, production frontend build, Rust formatting, +workspace Clippy with warnings denied, and locked native Windows workspace +tests. Visual verification must cover an in-progress node, ready node, blocked +node, completed node, selected ready node, and selected in-progress node. + +## Non-Goals + +This change does not implement a true three-dimensional graph, migrate to a +different rendering library, create or infer tracker assignments, relabel +existing issues, change dependency semantics, highlight transitive paths, +change timeline topology, or redesign the detail pane. diff --git a/scripts/build-linux-packages.sh b/scripts/build-linux-packages.sh index 04b0bf1..069eebd 100644 --- a/scripts/build-linux-packages.sh +++ b/scripts/build-linux-packages.sh @@ -27,7 +27,7 @@ fi npm --prefix "$repo/web" run build ( cd "$repo/crates/app" - "$cli" build --bundles appimage,deb + "$cli" build --features desktop --bundles appimage,deb ) appimage="$(find "$repo/target/release/bundle/appimage" -maxdepth 1 -type f -name '*.AppImage' -print -quit)" diff --git a/scripts/build-macos-universal.sh b/scripts/build-macos-universal.sh index 0f3c350..45abc09 100644 --- a/scripts/build-macos-universal.sh +++ b/scripts/build-macos-universal.sh @@ -27,7 +27,7 @@ fi npm --prefix "$repo/web" run build ( cd "$repo/crates/app" - "$cli" build --bundles dmg --target universal-apple-darwin + "$cli" build --features desktop --bundles dmg --target universal-apple-darwin ) bundle_root="$repo/target/universal-apple-darwin/release/bundle" diff --git a/scripts/build-windows-nsis.ps1 b/scripts/build-windows-nsis.ps1 index e61c1c9..23cfd3a 100644 --- a/scripts/build-windows-nsis.ps1 +++ b/scripts/build-windows-nsis.ps1 @@ -25,7 +25,7 @@ if ($Channel -eq 'Release' -and [string]::IsNullOrWhiteSpace($CertificateThumbpr & npm --prefix (Join-Path $repo 'web') run build if ($LASTEXITCODE -ne 0) { throw "Frontend build failed with exit code $LASTEXITCODE." } -$tauriArguments = @('build', '--bundles', 'nsis') +$tauriArguments = @('build', '--features', 'desktop', '--bundles', 'nsis') $temporaryBundleConfig = $null $hostTuple = (& rustc --print host-tuple).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($hostTuple)) { diff --git a/scripts/get-stellr-tailnet-url.cmd b/scripts/get-stellr-tailnet-url.cmd new file mode 100644 index 0000000..37393cd --- /dev/null +++ b/scripts/get-stellr-tailnet-url.cmd @@ -0,0 +1,11 @@ +@echo off +setlocal EnableExtensions + +where powershell.exe >nul 2>nul +if errorlevel 1 ( + echo Windows PowerShell is required but powershell.exe was not found on PATH. 1>&2 + exit /b 1 +) + +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0get-stellr-tailnet-url.ps1" %* +exit /b %errorlevel% diff --git a/scripts/get-stellr-tailnet-url.ps1 b/scripts/get-stellr-tailnet-url.ps1 new file mode 100644 index 0000000..69fe5a4 --- /dev/null +++ b/scripts/get-stellr-tailnet-url.ps1 @@ -0,0 +1,31 @@ +param( + [string]$HostName = 'amd-halo' +) + +$ErrorActionPreference = 'Stop' +$userName = if ([string]::IsNullOrWhiteSpace($env:STELLR_SSH_USER)) { + 'pfdev' +} else { + $env:STELLR_SSH_USER +} +$remoteFile = '~/dev/stellr/target/stellr-tailnet-url.txt' + +if ($null -eq (Get-Command ssh -ErrorAction SilentlyContinue)) { + throw 'OpenSSH is required but ssh was not found on PATH.' +} + +$url = (& ssh "$userName@$HostName" "cat $remoteFile" | Out-String).Trim() +if ($LASTEXITCODE -ne 0) { + throw "Could not retrieve the Stellr URL from $HostName (ssh exit code $LASTEXITCODE)." +} + +$parsed = $null +if (-not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref]$parsed) -or + ($parsed.Scheme -ne 'http' -and $parsed.Scheme -ne 'https')) { + throw "The Stellr URL returned by $HostName is not an HTTP(S) URL." +} +if ($parsed.Query -notmatch '(?:^|[?&])token=') { + throw "The Stellr URL returned by $HostName does not contain a session token." +} + +Write-Output $url diff --git a/scripts/get-stellr-tailnet-url.sh b/scripts/get-stellr-tailnet-url.sh new file mode 100755 index 0000000..b07a991 --- /dev/null +++ b/scripts/get-stellr-tailnet-url.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +host="${1:-amd-halo}" +user="${STELLR_SSH_USER:-pfdev}" +remote_file='~/dev/stellr/target/stellr-tailnet-url.txt' + +if ! command -v ssh >/dev/null 2>&1; then + printf 'OpenSSH is required but ssh was not found on PATH.\n' >&2 + exit 1 +fi + +if ! url="$(ssh "$user@$host" "cat $remote_file")"; then + printf 'Could not retrieve the Stellr URL from %s. Verify SSH access for %s@%s.\n' \ + "$host" "$user" "$host" >&2 + exit 1 +fi +url="${url//$'\r'/}" +url="${url//$'\n'/}" + +if [[ "$url" != http://* && "$url" != https://* ]]; then + printf 'The Stellr URL returned by %s is not an HTTP(S) URL.\n' "$host" >&2 + exit 1 +fi +if [[ "$url" != *'?token='* && "$url" != *'&token='* ]]; then + printf 'The Stellr URL returned by %s does not contain a session token.\n' "$host" >&2 + exit 1 +fi + +printf '%s\n' "$url" diff --git a/scripts/install-linux-dependencies.sh b/scripts/install-linux-dependencies.sh new file mode 100755 index 0000000..a0fd899 --- /dev/null +++ b/scripts/install-linux-dependencies.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != 'Linux' ]]; then + printf 'Linux development dependencies must be installed on native Linux.\n' >&2 + exit 1 +fi + +if ! command -v apt-get >/dev/null 2>&1; then + printf 'This installer supports Debian and Ubuntu through apt-get.\n' >&2 + printf 'Install the equivalent Tauri 2 WebKitGTK 4.1 development packages for your distribution.\n' >&2 + exit 1 +fi + +apt=(apt-get) +if (( EUID != 0 )); then + if ! command -v sudo >/dev/null 2>&1; then + printf 'Run this script as root or install sudo first.\n' >&2 + exit 1 + fi + apt=(sudo apt-get) +fi + +"${apt[@]}" update +"${apt[@]}" install -y \ + build-essential \ + curl \ + file \ + libayatana-appindicator3-dev \ + libdbus-1-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + patchelf \ + pkg-config \ + wget + +printf 'LINUX_DEVELOPMENT_DEPENDENCIES_INSTALLED=true\n' diff --git a/scripts/serve-tailnet.sh b/scripts/serve-tailnet.sh new file mode 100755 index 0000000..ca7fcbb --- /dev/null +++ b/scripts/serve-tailnet.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +binary="$repo/target/release/stellr" +port="${STELLR_PORT:-8787}" +url_log="$repo/target/stellr-tailnet-url.txt" + +if ! command -v tailscale >/dev/null 2>&1; then + printf 'Tailscale is not installed or is not available on PATH.\n' >&2 + exit 1 +fi + +if [[ ! "$port" =~ ^[1-9][0-9]{0,4}$ ]] || (( port > 65535 )); then + printf 'STELLR_PORT must be an integer from 1 through 65535.\n' >&2 + exit 1 +fi + +tailnet_ip="$(tailscale ip -4 | sed -n '1p')" +if [[ -z "$tailnet_ip" ]]; then + printf 'Tailscale did not report a Tailnet IPv4 address. Is it connected?\n' >&2 + exit 1 +fi + +if [[ ! -x "$binary" ]]; then + printf 'The release server is missing. Build it with:\n' >&2 + printf ' cargo build --release -p stellr-app --bin stellr --locked\n' >&2 + exit 1 +fi + +stop_existing_server() { + local process pid executable argument index + local -a arguments + local has_serve address_matches + + for process in /proc/[0-9]*; do + pid="${process##*/}" + executable="$(readlink "$process/exe" 2>/dev/null || true)" + + arguments=() + while IFS= read -r -d '' argument; do + arguments+=("$argument") + done < "$process/cmdline" 2>/dev/null || continue + [[ "$executable" == "$binary" \ + || "$executable" == "$binary (deleted)" \ + || "${arguments[0]:-}" == "$binary" ]] || continue + + has_serve=false + address_matches=false + for (( index = 0; index + 1 < ${#arguments[@]}; index++ )); do + [[ "${arguments[$index]}" == 'serve' ]] && has_serve=true + if [[ "${arguments[$index]}" == '--addr' \ + && "${arguments[$((index + 1))]}" == "$tailnet_ip:$port" ]]; then + address_matches=true + fi + done + + [[ "$has_serve" == true && "$address_matches" == true ]] || continue + printf 'Stopping existing Stellr Tailnet server (PID %s).\n' "$pid" + kill -TERM "$pid" + for _ in {1..50}; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.1 + done + if kill -0 "$pid" 2>/dev/null; then + printf 'The existing Stellr server did not stop within five seconds.\n' >&2 + exit 1 + fi + done +} + +stop_existing_server + +session_token='' +if [[ -s "$url_log" ]]; then + previous_url="$(tr -d '\r\n' < "$url_log")" + expected_prefix="http://$tailnet_ip:$port/?token=" + candidate_token="${previous_url#"$expected_prefix"}" + if [[ "$previous_url" == "$expected_prefix$candidate_token" \ + && "$candidate_token" =~ ^[0-9a-f]{32}$ ]]; then + session_token="$candidate_token" + fi +fi + +printf 'Serving Stellr on the Tailnet at http://%s:%s/\n' "$tailnet_ip" "$port" +mkdir -p "$repo/target" +umask 077 +printf 'The complete authenticated URL will also be saved to %s\n' "$url_log" +if [[ -n "$session_token" ]]; then + export STELLR_SESSION_TOKEN="$session_token" + printf 'Reusing the existing Tailnet session so open browser tabs can reconnect.\n' +fi +exec "$binary" serve --addr "$tailnet_ip:$port" > >( + while IFS= read -r line; do + if [[ "$line" == 'stellr cockpit: '* ]]; then + url="${line#stellr cockpit: }" + printf '%s\n' "$url" > "$url_log" + printf 'Open Stellr from any Tailnet device:\n%s\n' "$url" + else + printf '%s\n' "$line" + fi + done +) diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/package-lock.json b/web/package-lock.json index 33d780e..09f307d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -26,6 +26,10 @@ "typescript": "~6.0.2", "vite": "^8.2.0", "vitest": "^4.1.10" + }, + "engines": { + "node": ">=24 <25", + "npm": "12.0.2" } }, "node_modules/@asamuzakjp/css-color": { @@ -1787,9 +1791,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -2307,9 +2311,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "devOptional": true, "funding": [ { diff --git a/web/package.json b/web/package.json index f749ff0..463d201 100644 --- a/web/package.json +++ b/web/package.json @@ -3,10 +3,16 @@ "private": true, "version": "0.0.0", "type": "module", + "packageManager": "npm@12.0.2", + "engines": { + "node": ">=24 <25", + "npm": "12.0.2" + }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", + "serve:tailnet": "bash ../scripts/serve-tailnet.sh", "check": "svelte-check --tsconfig ./tsconfig.json", "test": "vitest run" }, @@ -31,10 +37,15 @@ "phosphor-svelte": "^3.1.0" }, "devEngines": { + "runtime": { + "name": "node", + "version": ">=24 <25", + "onFail": "error" + }, "packageManager": { "name": "npm", "version": "12.0.2", - "onFail": "download" + "onFail": "error" } } } diff --git a/web/src/App.svelte b/web/src/App.svelte index e76ed1c..35cb00a 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -5,9 +5,17 @@ import Sidebar from './lib/Sidebar.svelte' import StatePanel from './lib/StatePanel.svelte' import StarMap from './lib/StarMap.svelte' + import TemporalTimeline from './lib/TemporalTimeline.svelte' import { addSpace, removeSpace } from './lib/api' import { Control, pageIssue, takePageToken } from './lib/control.svelte' import type { Model } from './lib/model' + import { fetchHistory } from './lib/history-api' + import { + latestHistorySequence, + mergeHistoryEvents, + projectTemporalSpace, + type HistoryEvent, + } from './lib/history' import { Route } from './lib/route.svelte' import { ThemeController } from './lib/theme.svelte' import { decideDock, type Dock } from './lib/starmap/dock' @@ -77,6 +85,17 @@ ) const activeSpace = $derived(resolvedRoute.space) const activeStar = $derived(resolvedRoute.star) + let historyEvents = $state.raw([]) + let historyPlayhead = $state(null) + let historyFeedback = $state.raw([]) + let historyHasNewActivity = $state(false) + let historySpaceId = $state(null) + let requestedHistoryKey: string | null = null + const temporalSpace = $derived( + activeSpace === null + ? null + : projectTemporalSpace(activeSpace, historyEvents, historyPlayhead), + ) let workspace: HTMLElement let dock = $state('right') let pendingRemovals = $state.raw>({}) @@ -92,6 +111,41 @@ (nativeRoutePersistence && control.revision === 1 && spaces.length === 0 && route.space !== null), ) + $effect(() => { + const space = activeSpace + if (historySpaceId !== space?.id) { + historySpaceId = space?.id ?? null + historyEvents = [] + historyPlayhead = null + historyFeedback = [] + historyHasNewActivity = false + requestedHistoryKey = null + } + const summary = space?.history + if (space === null || summary?.verified_through === null || summary?.verified_through === undefined) return + const key = `${space.id}:${summary.revision}` + if (requestedHistoryKey === key) return + requestedHistoryKey = key + const after = latestHistorySequence(historyEvents) + void fetchHistory(space.id, after) + .then((response) => { + if (historySpaceId !== space.id) return + const merged = mergeHistoryEvents(historyEvents, response.events) + if (historyPlayhead !== null && merged.length > historyEvents.length) { + historyHasNewActivity = true + } + historyEvents = merged + }) + .catch(() => { + if (requestedHistoryKey === key) requestedHistoryKey = null + }) + }) + + function setHistoryPlayhead(playhead: number | null): void { + historyPlayhead = playhead + if (playhead === null) historyHasNewActivity = false + } + async function pollNativeRoute(): Promise { if (nativeRouteBusy) return nativeRouteBusy = true @@ -373,15 +427,21 @@ /> {/if}
- {#if modelLoading} + {#if control.status === 'unauthorized'} + + {:else if modelLoading} - {:else if activeSpace} + {:else if activeSpace && temporalSpace} route.go(activeSpace.id, issueNumber)} @@ -389,7 +449,20 @@ ready={committedLayout} cancelled={cancelledLayout} failed={failedLayout} + bottomInset={activeSpace.history === undefined ? 16 : 88} + replayEvents={historyFeedback} /> + {#if activeSpace.history !== undefined} + (historyFeedback = events)} + newActivity={historyHasNewActivity} + returnToNow={() => setHistoryPlayhead(null)} + /> + {/if} {:else} { expect(target.textContent).toContain('Retry') }) + it('loads history once, scrubs and plays locally without disturbing selection', async () => { + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + window.history.replaceState(null, '', '/#s=first&i=11') + const fetchRequest = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + summary: completeHistory, + events: [ + { + sequence: 1, + repository_id: 'R_first', + issue_id: 'I_11', + issue_number: 11, + provider_event_id: 'I_11:issue_created', + occurred_at: 100, + kind: 'issue_created', + milestone: null, + }, + { + sequence: 2, + repository_id: 'R_first', + issue_id: 'I_12', + issue_number: 12, + provider_event_id: 'I_12:issue_created', + occurred_at: 200, + kind: 'issue_created', + milestone: null, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchRequest) + const setModel = vi.spyOn(Renderer.prototype, 'setModel') + const replayHistory = vi.spyOn(Renderer.prototype, 'replayHistory') + const { target, socket } = mountApp() + socket.emitModel({ + spaces: [ + { + ...space('first', 11), + stars: [issue(11), issue(12)], + history: completeHistory, + }, + ], + }) + await settle() + await settle() + + expect(fetchRequest).toHaveBeenCalledTimes(1) + expect(fetchRequest).toHaveBeenCalledWith( + '/api/spaces/first/history', + expect.objectContaining({ credentials: 'same-origin' }), + ) + const slider = target.querySelector('input[type="range"]')! + expect(slider).not.toBeNull() + + slider.value = '100' + slider.dispatchEvent(new Event('input', { bubbles: true })) + flushSync() + slider.dispatchEvent(new Event('input', { bubbles: true })) + flushSync() + + expect(fetchRequest).toHaveBeenCalledTimes(1) + expect(setModel).toHaveBeenLastCalledWith( + [ + expect.objectContaining({ num: 11, status: 'open', visible: true }), + expect.objectContaining({ num: 12, status: 'open', visible: false }), + ], + {}, + null, + ) + + target.querySelector('[data-control="play"]')!.click() + flushSync() + + expect(replayHistory).toHaveBeenCalledWith( + [expect.objectContaining({ issue_number: 11, kind: 'issue_created' })], + false, + ) + expect(fetchRequest).toHaveBeenCalledTimes(1) + expect(window.location.hash).toBe('#s=first&i=11') + expect(target.textContent).toContain('Detail for Issue 11') + }) + + it('requests only later ledger sequences and keeps a past playhead pinned', async () => { + const initialEvents = [ + { + sequence: 1, + repository_id: 'R_first', + issue_id: 'I_11', + issue_number: 11, + provider_event_id: 'I_11:issue_created', + occurred_at: 100, + kind: 'issue_created', + milestone: null, + }, + { + sequence: 2, + repository_id: 'R_first', + issue_id: 'I_12', + issue_number: 12, + provider_event_id: 'I_12:issue_created', + occurred_at: 200, + kind: 'issue_created', + milestone: null, + }, + ] + const fetchRequest = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ summary: completeHistory, events: initialEvents }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + summary: { ...completeHistory, revision: 3, verified_through: 300 }, + events: [ + { + sequence: 3, + repository_id: 'R_first', + issue_id: 'I_11', + issue_number: 11, + provider_event_id: 'E_close', + occurred_at: 250, + kind: 'issue_closed', + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchRequest) + const setModel = vi.spyOn(Renderer.prototype, 'setModel') + const { target, socket } = mountApp() + socket.emitModel({ + spaces: [ + { + ...space('first', 11), + stars: [issue(11), issue(12)], + history: completeHistory, + }, + ], + }) + await settle() + await settle() + + const slider = target.querySelector('input[type="range"]')! + slider.value = '100' + slider.dispatchEvent(new Event('input', { bubbles: true })) + flushSync() + + socket.emitModel({ + spaces: [ + { + ...space('first', 11), + stars: [issue(11), issue(12)], + history: { ...completeHistory, revision: 3, verified_through: 300 }, + }, + ], + }) + await settle() + await settle() + + expect(fetchRequest).toHaveBeenNthCalledWith( + 2, + '/api/spaces/first/history?after=2', + expect.objectContaining({ credentials: 'same-origin' }), + ) + expect(slider.value).toBe('100') + const newActivity = target.querySelector('.new-activity')! + expect(newActivity).not.toBeNull() + newActivity.click() + flushSync() + expect(target.querySelector('output')?.textContent).toBe('Now') + expect(target.querySelector('.new-activity')).toBeNull() + expect(setModel).toHaveBeenLastCalledWith( + [ + expect.objectContaining({ num: 11, visible: true }), + expect.objectContaining({ num: 12, visible: true }), + ], + {}, + null, + ) + expect(fetchRequest).toHaveBeenCalledTimes(2) + }) + it('distinguishes runtime loading from an authoritative empty model', () => { const { target, socket } = mountApp() diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 37327d2..b787639 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -8,8 +8,8 @@ hasNativeShell, type ThemePreference, } from './native-shell' + import type { ConnectionStatus } from './control.svelte' - type Status = 'connecting' | 'open' | 'closed' type RowAction = 'refresh' | 'remove' interface RowMutation { @@ -21,7 +21,7 @@ interface SidebarProps { spaces: SpaceModel[] activeSpaceId: string | null - connectionStatus: Status + connectionStatus: ConnectionStatus select: (id: string) => void added: (id: string) => void removed: (id: string) => void @@ -92,7 +92,8 @@ return `Synced ${days} ${days === 1 ? 'day' : 'days'} ago` } - function connectionLabel(status: Status): string { + function connectionLabel(status: ConnectionStatus): string { + if (status === 'unauthorized') return 'Session expired' return status[0].toUpperCase() + status.slice(1) } diff --git a/web/src/lib/Sidebar.test.ts b/web/src/lib/Sidebar.test.ts index f63d0d8..024a56c 100644 --- a/web/src/lib/Sidebar.test.ts +++ b/web/src/lib/Sidebar.test.ts @@ -3,6 +3,7 @@ import { flushSync, mount, unmount } from 'svelte' import Sidebar from './Sidebar.svelte' import type { addSpace, refreshSpace, removeSpace } from './api' import type { chooseRepositoryDirectory } from './native-shell' +import type { ConnectionStatus } from './control.svelte' import type { SpaceModel } from './model' const mounted: object[] = [] @@ -32,7 +33,7 @@ function render( spaces: SpaceModel[], overrides: { activeSpaceId?: string | null - connectionStatus?: 'connecting' | 'open' | 'closed' + connectionStatus?: ConnectionStatus select?: (id: string) => void added?: (id: string) => void removed?: (id: string) => void @@ -124,6 +125,12 @@ describe('Sidebar display', () => { .dispatchEvent(new MouseEvent('click', { bubbles: true })) expect(selected).toEqual(['cached-space']) }) + + it('labels an unauthorized connection as an expired session', () => { + const target = render([], { connectionStatus: 'unauthorized' }) + + expect(target.textContent).toContain('Session expired') + }) }) describe('Sidebar add form', () => { diff --git a/web/src/lib/StarMap.svelte b/web/src/lib/StarMap.svelte index 1935486..0279779 100644 --- a/web/src/lib/StarMap.svelte +++ b/web/src/lib/StarMap.svelte @@ -1,6 +1,7 @@ ' }], + }) + flushSync() + + expect(setModel).toHaveBeenLastCalledWith( + [expect.objectContaining({ num: 42, milestone: 'Beta ' })], + {}, + null, + ) + expect(select).not.toHaveBeenCalled() + }) + it('passes the current conversation issue to the renderer', () => { const setModel = vi.spyOn(Renderer.prototype, 'setModel') const target = document.createElement('div') @@ -186,6 +249,71 @@ describe('StarMap wrapper', () => { ) }) + it('labels current dependencies and suppresses live focus in historical mode', () => { + const setModel = vi.spyOn(Renderer.prototype, 'setModel') + const target = document.createElement('div') + document.body.appendChild(target) + const input = space(42) + const historical = projectTemporalSpace( + input, + [ + { + sequence: 1, + repository_id: 'R_repo', + issue_id: 'I_42', + issue_number: 42, + provider_event_id: 'I_42:issue_created', + occurred_at: 100, + kind: 'issue_created', + milestone: null, + }, + ], + 100, + ) + + const component = mount(StarMap, { + target, + props: { space: historical, currentIssue: 14, layout: immediateLayout }, + }) + mounted.push(component) + flushSync() + + expect(target.textContent).toContain('Current dependencies') + expect(setModel).toHaveBeenLastCalledWith( + [expect.objectContaining({ num: 42, historical: true, frontier: false })], + {}, + 14, + expect.objectContaining({ 42: expect.any(Object) }), + ) + }) + + it('forwards reached history events with the reduced-motion preference', () => { + const replayHistory = vi.spyOn(Renderer.prototype, 'replayHistory') + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + const target = document.createElement('div') + document.body.appendChild(target) + const replayEvents: HistoryEvent[] = [ + { + sequence: 1, + repository_id: 'R_repo', + issue_id: 'I_42', + issue_number: 42, + provider_event_id: 'E_reopen', + occurred_at: 100, + kind: 'issue_reopened', + }, + ] + + const component = mount(StarMap, { + target, + props: { space: space(42), replayEvents }, + }) + mounted.push(component) + flushSync() + + expect(replayHistory).toHaveBeenCalledWith(replayEvents, true) + }) + it('does not echo a routed selection back through the user-selection callback', () => { const target = document.createElement('div') document.body.appendChild(target) diff --git a/web/src/lib/TemporalTimeline.svelte b/web/src/lib/TemporalTimeline.svelte new file mode 100644 index 0000000..76684a3 --- /dev/null +++ b/web/src/lib/TemporalTimeline.svelte @@ -0,0 +1,423 @@ + + +
+ {#if summary.state === 'building'} + {importStatus()} + {:else if summary.state === 'complete' && !hasHistory} + No issue history + {:else if summary.state !== 'complete'} + {importStatus()} + {/if} + + {#if newActivity && playhead !== null} + + {/if} + + {label} +
+ +
+ {#each ticks as tick, index} + + {/each} +
+ {#if tooltip !== null} + + {/if} +
+ + +
+ + diff --git a/web/src/lib/TemporalTimeline.test.ts b/web/src/lib/TemporalTimeline.test.ts new file mode 100644 index 0000000..9ffc4a8 --- /dev/null +++ b/web/src/lib/TemporalTimeline.test.ts @@ -0,0 +1,288 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushSync, mount, unmount, type ComponentProps } from 'svelte' +import TemporalTimeline from './TemporalTimeline.svelte' +import type { HistoryEvent } from './history' +import type { HistorySummary } from './model' + +const mounted: object[] = [] + +afterEach(async () => { + for (const component of mounted.splice(0)) await unmount(component) + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +const complete: HistorySummary = { + state: 'complete', + completed_issues: 2, + total_issues: 2, + earliest_event_at: 100, + verified_through: 200, + revision: 2, + diagnostic: null, + resume_at: null, +} + +const events: HistoryEvent[] = [ + { + sequence: 1, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'I_1:issue_created', + occurred_at: 100, + kind: 'issue_created', + milestone: null, + }, + { + sequence: 2, + repository_id: 'R_repo', + issue_id: 'I_2', + issue_number: 2, + provider_event_id: 'E_close', + occurred_at: 150, + kind: 'issue_closed', + }, + { + sequence: 3, + repository_id: 'R_repo', + issue_id: 'I_2', + issue_number: 2, + provider_event_id: 'E_reopen', + occurred_at: 150, + kind: 'issue_reopened', + }, + { + sequence: 4, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'E_milestone', + occurred_at: 151, + kind: 'milestone_changed', + from: null, + to: { id: null, title: 'M2 ' }, + }, + { + sequence: 5, + repository_id: 'R_repo', + issue_id: 'I_2', + issue_number: 2, + provider_event_id: 'E_last', + occurred_at: 200, + kind: 'issue_closed', + }, +] + +function render(props: ComponentProps) { + const target = document.createElement('div') + document.body.appendChild(target) + const component = mount(TemporalTimeline, { target, props }) + mounted.push(component) + flushSync() + return target +} + +describe('TemporalTimeline creation scrubber', () => { + it('stays visible but disabled with determinate import progress', () => { + const target = render({ + summary: { + ...complete, + state: 'building', + completed_issues: 3, + total_issues: 10, + verified_through: null, + }, + events: [], + playhead: null, + }) + + expect(target.querySelector('input[type="range"]')).toHaveProperty('disabled', true) + expect(target.textContent).toContain('Building history · 3/10 issues') + }) + + it('keeps verified local history usable while a delta catch-up is building', () => { + const target = render({ + summary: { ...complete, state: 'building', completed_issues: 1, total_issues: 2 }, + events, + playhead: 125, + }) + + expect(target.querySelector('input[type="range"]')).toHaveProperty('disabled', false) + expect(target.textContent).toContain('Updating history · 1/2 issues') + expect(target.textContent).toContain('History through') + }) + + it('reports rate-limit reset evidence without disabling verified playback', () => { + const target = render({ + summary: { + ...complete, + state: 'rate_limited', + diagnostic: 'GitHub rate limit exceeded', + resume_at: 300, + }, + events, + playhead: null, + }) + + const status = target.querySelector('[role="status"]') + expect(status?.textContent).toContain('GitHub rate limit exceeded') + expect(status?.textContent).toContain('Retry after') + expect(status?.textContent).toContain('History through') + expect(target.querySelector('input[type="range"]')).toHaveProperty('disabled', false) + }) + + it('keeps persistent accessible names across enabled and unavailable states', () => { + const enabled = render({ summary: complete, events, playhead: null }) + expect(enabled.querySelector('input[type="range"]')?.getAttribute('aria-label')).toBe( + 'Issue history date', + ) + expect(enabled.querySelector('[data-control="play"]')?.getAttribute('aria-label')).toBe( + 'Play issue history', + ) + expect(enabled.querySelector('[data-control="speed"]')?.getAttribute('aria-label')).toContain( + 'Playback speed', + ) + + const unavailable = render({ + summary: { + ...complete, + state: 'failed', + earliest_event_at: null, + verified_through: null, + diagnostic: 'History unavailable offline', + }, + events: [], + playhead: null, + }) + expect(unavailable.querySelector('input[type="range"]')?.getAttribute('aria-label')).toBe( + 'Issue history date', + ) + expect(unavailable.querySelector('[data-control="play"]')?.getAttribute('aria-label')).toBe( + 'Play issue history', + ) + }) + + it('reports an empty complete ledger without enabling a slider', () => { + const target = render({ + summary: { ...complete, earliest_event_at: null, revision: 0 }, + events: [], + playhead: null, + }) + + expect(target.textContent).toContain('No issue history') + expect(target.querySelector('input[type="range"]')).toHaveProperty('disabled', true) + }) + + it('defaults to Now and emits local scrub changes', () => { + const change = vi.fn() + const target = render({ summary: complete, events, playhead: null, change }) + const slider = target.querySelector('input[type="range"]')! + + expect(target.querySelector('output')?.textContent).toBe('Now') + expect(slider.value).toBe('200') + + slider.value = '100' + slider.dispatchEvent(new Event('input', { bubbles: true })) + flushSync() + expect(change).toHaveBeenCalledWith(100) + }) + + it('orders date, slider ticks, Play, and speed controls accessibly', () => { + const target = render({ summary: complete, events, playhead: null }) + const controls = [...target.querySelectorAll('[data-control]')] + + expect(controls.map((control) => control.dataset.control)).toEqual([ + 'date', + 'track', + 'play', + 'speed', + ]) + expect(target.querySelector('input[type="range"]')?.getAttribute('aria-valuetext')).toBe('Now') + expect(target.querySelector('[data-control="play"]')?.textContent).toContain('Play') + expect(target.querySelector('[data-control="speed"]')?.textContent).toContain('1×') + }) + + it('renders proportional ticks, clusters dense activity, and exposes ordered tooltip text', () => { + const target = render({ summary: complete, events, playhead: null }) + const ticks = [...target.querySelectorAll('.event-tick')] + + expect(ticks).toHaveLength(3) + expect(ticks[0].style.left).toBe('0%') + expect(ticks.at(-1)?.style.left).toBe('100%') + expect(ticks[1].dataset.eventCount).toBe('3') + + ticks[1].focus() + ticks[1].dispatchEvent(new FocusEvent('focus', { bubbles: true })) + flushSync() + const tooltip = target.querySelector('[role="tooltip"]') + expect(tooltip?.textContent).toContain('#2 closed') + expect(tooltip?.textContent).toContain('#2 reopened') + expect(tooltip?.textContent).toContain('#1 moved to M2 ') + expect(tooltip?.querySelector('script')).toBeNull() + }) + + it('navigates exact event times with ticks and slider keys', () => { + const change = vi.fn() + const target = render({ summary: complete, events, playhead: 150, change }) + const slider = target.querySelector('input[type="range"]')! + const ticks = [...target.querySelectorAll('.event-tick')] + + ticks[0].click() + expect(change).toHaveBeenLastCalledWith(100) + + slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + expect(change).toHaveBeenLastCalledWith(151) + slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })) + expect(change).toHaveBeenLastCalledWith(100) + slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })) + expect(change).toHaveBeenLastCalledWith(100) + slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })) + expect(change).toHaveBeenLastCalledWith(null) + }) + + it('plays from the beginning at Now, emits crossed events, pauses, and cycles speed', () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + vi.spyOn(performance, 'now').mockReturnValue(1_000) + const change = vi.fn() + const reached = vi.fn() + const target = render({ summary: complete, events, playhead: null, change, reached }) + const play = target.querySelector('[data-control="play"]')! + const speed = target.querySelector('[data-control="speed"]')! + + play.click() + flushSync() + expect(change).toHaveBeenLastCalledWith(100) + expect(reached.mock.calls[0][0].map((event: HistoryEvent) => event.provider_event_id)).toEqual([ + 'I_1:issue_created', + ]) + expect(play.textContent).toContain('Pause') + + speed.click() + flushSync() + expect(speed.textContent).toContain('2×') + play.click() + flushSync() + expect(play.textContent).toContain('Play') + }) + + it('offers new activity without moving a historical playhead', () => { + const returnToNow = vi.fn() + const target = render({ + summary: { ...complete, revision: 6 }, + events, + playhead: 125, + newActivity: true, + returnToNow, + }) + + expect(target.querySelector('input[type="range"]')?.value).toBe('125') + target.querySelector('.new-activity')!.click() + expect(returnToNow).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/lib/control.svelte.ts b/web/src/lib/control.svelte.ts index 8b6216b..8d12375 100644 --- a/web/src/lib/control.svelte.ts +++ b/web/src/lib/control.svelte.ts @@ -1,6 +1,11 @@ import type { Model } from './model' -type ConnectionStatus = 'connecting' | 'open' | 'closed' +export type ConnectionStatus = 'connecting' | 'open' | 'closed' | 'unauthorized' +type SessionProbe = () => Promise + +function probeSession(): Promise { + return fetch('/api/model', { credentials: 'same-origin' }) +} export function pageIssue(): number | null { const raw = new URL(window.location.href).searchParams.get('issue') @@ -49,8 +54,14 @@ export class Control { #token: string | null #url: string | null = null - constructor(token: string | null = takePageToken()) { + #sessionProbe: SessionProbe + + constructor( + token: string | null = takePageToken(), + sessionProbe: SessionProbe = probeSession, + ) { this.#token = token + this.#sessionProbe = sessionProbe } connect(url?: string): void { @@ -124,6 +135,31 @@ export class Control { this.#open(url) } }, 500) + void this.#detectExpiredSession(url) + } + } + + async #detectExpiredSession(url: string): Promise { + let response: Response + try { + response = await this.#sessionProbe() + } catch { + return + } + if (response.status !== 401 || this.#url !== url) { + return + } + + this.#url = null + this.#clearReconnect() + const socket = this.#socket + this.#socket = null + if (socket !== null) { + socket.onopen = null + socket.onmessage = null + socket.onclose = null + socket.close() } + this.status = 'unauthorized' } } diff --git a/web/src/lib/control.test.ts b/web/src/lib/control.test.ts index 0874d46..4fd6cbd 100644 --- a/web/src/lib/control.test.ts +++ b/web/src/lib/control.test.ts @@ -156,6 +156,21 @@ describe('Control', () => { expect(control.status).toBe('closed') }) + it('stops reconnecting when the server rejects the browser session', async () => { + const probe = vi.fn().mockResolvedValue(new Response(null, { status: 401 })) + const control = new Control(null, probe) + control.connect('ws://socket.example/ws/control') + + FakeWebSocket.instances[0].emitClose() + await Promise.resolve() + await Promise.resolve() + + expect(probe).toHaveBeenCalledOnce() + expect(control.status).toBe('unauthorized') + vi.advanceTimersByTime(500) + expect(FakeWebSocket.instances).toHaveLength(1) + }) + it('cancels pending reconnect work when destroyed', () => { const control = new Control() control.connect('ws://socket.example/ws/control') diff --git a/web/src/lib/history-api.ts b/web/src/lib/history-api.ts new file mode 100644 index 0000000..a8c139d --- /dev/null +++ b/web/src/lib/history-api.ts @@ -0,0 +1,16 @@ +import type { HistoryEvent } from './history' +import type { HistorySummary } from './model' + +export interface HistoryResponse { + summary: HistorySummary + events: HistoryEvent[] +} + +export async function fetchHistory(spaceId: string, after = 0): Promise { + const query = after > 0 ? `?after=${after}` : '' + const response = await fetch(`/api/spaces/${encodeURIComponent(spaceId)}/history${query}`, { + credentials: 'same-origin', + }) + if (!response.ok) throw new Error(`History request failed (${response.status})`) + return (await response.json()) as HistoryResponse +} diff --git a/web/src/lib/history.test.ts b/web/src/lib/history.test.ts new file mode 100644 index 0000000..afcc1ac --- /dev/null +++ b/web/src/lib/history.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest' +import { + latestHistorySequence, + mergeHistoryEvents, + projectTemporalSpace, + type HistoryEvent, +} from './history' +import type { SpaceModel } from './model' + +const space: SpaceModel = { + id: 'o-r', + repo: 'o/r', + name: 'r', + synced_at: 300, + stale: false, + error: null, + stars: [ + { + number: 1, + parent_issue: null, + title: 'First', + status: 'resolved', + blocked_by: [], + milestone: null, + labels: [], + assignees: [], + url: 'https://example.test/1', + body: '', + }, + { + number: 2, + parent_issue: null, + title: 'Second', + status: 'frontier', + blocked_by: [1], + milestone: null, + labels: ['ready-for-agent'], + assignees: [], + url: 'https://example.test/2', + body: '', + }, + ], +} + +const events: HistoryEvent[] = [ + { + sequence: 1, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'I_1:issue_created', + occurred_at: 100, + kind: 'issue_created', + milestone: null, + }, + { + sequence: 2, + repository_id: 'R_repo', + issue_id: 'I_2', + issue_number: 2, + provider_event_id: 'I_2:issue_created', + occurred_at: 200, + kind: 'issue_created', + milestone: null, + }, +] + +describe('temporal creation projection', () => { + it('keeps the complete current snapshot at Now', () => { + const projected = projectTemporalSpace(space, events, null) + + expect(projected.temporal_active).toBe(false) + expect(projected.stars.map((star) => [star.number, star.status, star.temporal_visible])).toEqual([ + [1, 'resolved', true], + [2, 'frontier', true], + ]) + }) + + it('uses exact creation boundaries and neutral historical-open status', () => { + expect(projectTemporalSpace(space, events, 100).temporal_active).toBe(true) + expect( + projectTemporalSpace(space, events, 99).stars.map((star) => star.temporal_visible), + ).toEqual([false, false]) + + expect( + projectTemporalSpace(space, events, 100).stars.map((star) => [ + star.number, + star.status, + star.temporal_visible, + ]), + ).toEqual([ + [1, 'open', true], + [2, 'open', false], + ]) + + expect( + projectTemporalSpace(space, events, 200).stars.map((star) => star.temporal_visible), + ).toEqual([true, true]) + }) + + it('preserves every present-day star and structural edge while scrubbing', () => { + const before = projectTemporalSpace(space, events, 99) + const after = projectTemporalSpace(space, events, 200) + + expect(before.stars.map((star) => star.number)).toEqual([1, 2]) + expect(after.stars.map((star) => star.number)).toEqual([1, 2]) + expect(before.stars.map((star) => star.blocked_by)).toEqual([[], [1]]) + expect(after.stars.map((star) => star.blocked_by)).toEqual([[], [1]]) + }) +}) + +describe('temporal lifecycle projection', () => { + const lifecycle: HistoryEvent[] = [ + ...events, + { + sequence: 3, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'E_close', + occurred_at: 150, + kind: 'issue_closed', + }, + { + sequence: 4, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'E_reopen', + occurred_at: 175, + kind: 'issue_reopened', + }, + ] + + it('applies close and reopen at their exact boundaries', () => { + expect(projectTemporalSpace(space, lifecycle, 149).stars[0].status).toBe('open') + expect(projectTemporalSpace(space, lifecycle, 150).stars[0].status).toBe('resolved') + expect(projectTemporalSpace(space, lifecycle, 174).stars[0].status).toBe('resolved') + expect(projectTemporalSpace(space, lifecycle, 175).stars[0].status).toBe('open') + }) + + it('orders same-timestamp transitions by provider event identity', () => { + const tied: HistoryEvent[] = [ + events[0], + { + sequence: 2, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'z-close', + occurred_at: 150, + kind: 'issue_closed', + }, + { + sequence: 3, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'a-reopen', + occurred_at: 150, + kind: 'issue_reopened', + }, + ] + + expect(projectTemporalSpace(space, tied, 150).stars[0].status).toBe('resolved') + }) +}) + +describe('temporal milestone projection', () => { + const milestoneEvents: HistoryEvent[] = [ + { + sequence: 1, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'I_1:issue_created', + occurred_at: 100, + kind: 'issue_created', + milestone: { id: 'M_alpha', title: 'Alpha' }, + }, + { + sequence: 2, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'E_assign_beta', + occurred_at: 125, + kind: 'milestone_changed', + from: { id: null, title: 'Alpha' }, + to: { id: null, title: 'Beta' }, + }, + { + sequence: 3, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'E_remove_beta', + occurred_at: 150, + kind: 'milestone_changed', + from: { id: null, title: 'Beta' }, + to: null, + }, + ] + + it('applies creation membership, movement, and removal at exact boundaries', () => { + expect(projectTemporalSpace(space, milestoneEvents, 99).stars[0].milestone).toBeNull() + expect(projectTemporalSpace(space, milestoneEvents, 100).stars[0].milestone).toBe('Alpha') + expect(projectTemporalSpace(space, milestoneEvents, 124).stars[0].milestone).toBe('Alpha') + expect(projectTemporalSpace(space, milestoneEvents, 125).stars[0].milestone).toBe('Beta') + expect(projectTemporalSpace(space, milestoneEvents, 149).stars[0].milestone).toBe('Beta') + expect(projectTemporalSpace(space, milestoneEvents, 150).stars[0].milestone).toBeNull() + }) + + it('orders same-timestamp milestone transitions by provider event identity', () => { + const tied: HistoryEvent[] = [ + events[0], + { + sequence: 2, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'a-assign', + occurred_at: 125, + kind: 'milestone_changed', + from: null, + to: { id: null, title: 'Alpha' }, + }, + { + sequence: 3, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'z-move', + occurred_at: 125, + kind: 'milestone_changed', + from: { id: null, title: 'Alpha' }, + to: { id: null, title: 'Beta' }, + }, + ] + + expect(projectTemporalSpace(space, tied, 125).stars[0].milestone).toBe('Beta') + }) +}) + +describe('history delta merge', () => { + it('deduplicates by ledger sequence or provider identity without losing stable order', () => { + const delta: HistoryEvent[] = [ + { + sequence: 3, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'E_close', + occurred_at: 150, + kind: 'issue_closed', + }, + { ...events[1], sequence: 99 }, + ] + + const merged = mergeHistoryEvents(events, delta) + + expect(merged.map((event) => event.provider_event_id)).toEqual([ + 'I_1:issue_created', + 'E_close', + 'I_2:issue_created', + ]) + expect(latestHistorySequence(merged)).toBe(99) + expect(mergeHistoryEvents(merged, delta)).toEqual(merged) + }) + + it('rejects a different provider event that reuses a ledger sequence', () => { + const collision: HistoryEvent = { + sequence: 2, + repository_id: 'R_repo', + issue_id: 'I_duplicate_sequence', + issue_number: 99, + provider_event_id: 'E_duplicate_sequence', + occurred_at: 125, + kind: 'issue_closed', + } + + expect(mergeHistoryEvents(events, [collision])).toEqual(events) + }) + + it('replaces a corrected provider event with its later ledger revision', () => { + const creation = events[0] as Extract + const corrected: HistoryEvent = { + ...creation, + sequence: 3, + milestone: { id: null, title: 'Alpha' }, + } + + const merged = mergeHistoryEvents(events, [corrected]) + + expect(merged).toHaveLength(2) + expect(merged[0]).toEqual(corrected) + expect(latestHistorySequence(merged)).toBe(3) + }) +}) diff --git a/web/src/lib/history.ts b/web/src/lib/history.ts new file mode 100644 index 0000000..a76776b --- /dev/null +++ b/web/src/lib/history.ts @@ -0,0 +1,130 @@ +import type { SpaceModel, Star, Status } from './model' + +export interface MilestoneRef { + id: string | null + title: string +} + +interface HistoryEventBase { + sequence: number + repository_id: string + issue_id: string + issue_number: number + provider_event_id: string + occurred_at: number +} + +export type HistoryEvent = HistoryEventBase & + ( + | { kind: 'issue_created'; milestone: MilestoneRef | null } + | { kind: 'issue_closed' } + | { kind: 'issue_reopened' } + | { + kind: 'milestone_changed' + from: MilestoneRef | null + to: MilestoneRef | null + } + ) + +export interface TemporalStar extends Omit { + status: Status | 'open' + live_status: Status + temporal_visible: boolean +} + +export interface TemporalSpace extends Omit { + temporal_active: boolean + stars: TemporalStar[] +} + +export function historyEventSummary(event: HistoryEvent, issueWidth = 0): string { + const issue = `#${event.issue_number.toString().padStart(issueWidth, '0')}` + if (event.kind === 'issue_created') return `${issue} created` + if (event.kind === 'issue_closed') return `${issue} closed` + if (event.kind === 'issue_reopened') return `${issue} reopened` + if (event.to === null) return `${issue} removed from milestone` + return `${issue} moved to ${event.to.title}` +} + +export function mergeHistoryEvents( + current: HistoryEvent[], + incoming: HistoryEvent[], +): HistoryEvent[] { + const sequences = new Set() + const providerEvents = new Map() + for (const event of [...current, ...incoming]) { + const providerKey = `${event.repository_id}\u0000${event.provider_event_id}` + if (sequences.has(event.sequence)) continue + sequences.add(event.sequence) + const previous = providerEvents.get(providerKey) + if (previous === undefined || event.sequence > previous.sequence) { + providerEvents.set(providerKey, event) + } + } + return [...providerEvents.values()].toSorted( + (left, right) => + left.occurred_at - right.occurred_at || + left.provider_event_id.localeCompare(right.provider_event_id), + ) +} + +export function latestHistorySequence(events: HistoryEvent[]): number { + return events.reduce((latest, event) => Math.max(latest, event.sequence), 0) +} + +export function projectTemporalSpace( + space: SpaceModel, + events: HistoryEvent[], + playhead: number | null, +): TemporalSpace { + if (playhead === null) { + return { + ...space, + temporal_active: false, + stars: space.stars.map((star) => ({ + ...star, + live_status: star.status, + temporal_visible: true, + })), + } + } + + const creationByIssue = new Map>() + const statusByIssue = new Map() + const milestoneByIssue = new Map() + const ordered = events + .filter((event) => event.occurred_at <= playhead) + .toSorted( + (left, right) => + left.occurred_at - right.occurred_at || + left.provider_event_id.localeCompare(right.provider_event_id), + ) + for (const event of ordered) { + if (event.kind === 'issue_created') { + creationByIssue.set(event.issue_number, event) + statusByIssue.set(event.issue_number, 'open') + milestoneByIssue.set(event.issue_number, event.milestone) + } else if (event.kind === 'issue_closed') { + statusByIssue.set(event.issue_number, 'resolved') + } else if (event.kind === 'issue_reopened') { + statusByIssue.set(event.issue_number, 'open') + } else if (event.kind === 'milestone_changed') { + milestoneByIssue.set(event.issue_number, event.to) + } + } + + return { + ...space, + temporal_active: true, + stars: space.stars.map((star) => { + const creation = creationByIssue.get(star.number) + return { + ...star, + live_status: star.status, + status: statusByIssue.get(star.number) ?? 'open', + milestone: milestoneByIssue.get(star.number)?.title ?? null, + temporal_visible: creation !== undefined, + } + }), + } +} diff --git a/web/src/lib/model.ts b/web/src/lib/model.ts index 9bd3100..551a380 100644 --- a/web/src/lib/model.ts +++ b/web/src/lib/model.ts @@ -1,5 +1,24 @@ export type Status = 'blocked' | 'frontier' | 'claimed' | 'resolved' | 'out_of_scope' +export type HistoryImportState = + | 'unavailable' + | 'building' + | 'complete' + | 'delayed' + | 'rate_limited' + | 'failed' + +export interface HistorySummary { + state: HistoryImportState + completed_issues: number + total_issues: number + earliest_event_at: number | null + verified_through: number | null + revision: number + diagnostic: string | null + resume_at: number | null +} + export interface Star { number: number parent_issue: number | null @@ -24,6 +43,7 @@ export interface SpaceModel { synced_at: number | null stale: boolean error: string | null + history?: HistorySummary } export interface Model { diff --git a/web/src/lib/playback.test.ts b/web/src/lib/playback.test.ts new file mode 100644 index 0000000..6e9cb74 --- /dev/null +++ b/web/src/lib/playback.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { PlaybackClock, clusterEventTicks, type PlaybackSpeed } from './playback' +import type { HistoryEvent } from './history' + +function created(id: string, issue: number, occurredAt: number): HistoryEvent { + return { + sequence: issue, + repository_id: 'R_repo', + issue_id: `I_${issue}`, + issue_number: issue, + provider_event_id: id, + occurred_at: occurredAt, + kind: 'issue_created', + milestone: null, + } +} + +const events = [ + created('E_first', 1, 100), + created('E_tied_b', 2, 150), + created('E_tied_a', 3, 150), + created('E_last', 4, 200), +] + +describe('PlaybackClock', () => { + it.each([ + [0.5, 60_000], + [1, 30_000], + [2, 15_000], + [4, 7_500], + ] as Array<[PlaybackSpeed, number]>)('maps the full range at %sx to %sms', (speed, duration) => { + const clock = new PlaybackClock(100, 200, events, speed) + const started = clock.play(null, 1_000) + + expect(started.playhead).toBe(100) + expect(started.crossed.map((event) => event.provider_event_id)).toEqual(['E_first']) + expect(clock.tick(1_000 + duration - 1).playing).toBe(true) + expect(clock.tick(1_000 + duration)).toMatchObject({ playhead: null, playing: false }) + }) + + it('continues from the current absolute playhead and pauses at Now', () => { + const clock = new PlaybackClock(100, 200, events) + expect(clock.play(150, 0).playhead).toBe(150) + expect(clock.tick(7_500).playhead).toBe(175) + expect(clock.tick(15_000)).toMatchObject({ playhead: null, playing: false }) + }) + + it('restarts at the first event when Play is pressed at Now', () => { + const clock = new PlaybackClock(100, 200, events) + clock.play(150, 0) + clock.pause() + + expect(clock.play(null, 10_000)).toMatchObject({ playhead: 100, playing: true }) + }) + + it('returns every crossed event in stable atomic order after a slow frame', () => { + const clock = new PlaybackClock(100, 200, events) + clock.play(100, 0) + + const frame = clock.tick(20_000) + + expect(frame.playhead).toBeCloseTo(166.666, 2) + expect(frame.crossed.map((event) => event.provider_event_id)).toEqual([ + 'E_tied_a', + 'E_tied_b', + ]) + }) + + it('changes speed without losing elapsed time at the transition', () => { + const clock = new PlaybackClock(100, 200, events) + clock.play(100, 0) + + const transition = clock.setSpeed(2, 7_500) + expect(transition.playhead).toBe(125) + expect(clock.tick(15_000).playhead).toBe(175) + }) +}) + +describe('event tick clustering', () => { + it('keeps proportional positions, groups exact ties, and clusters dense pixels', () => { + const clusters = clusterEventTicks( + [ + ...events, + created('E_dense_1', 5, 151), + created('E_dense_2', 6, 152), + created('E_dense_3', 7, 153), + ], + 100, + 200, + 500, + 8, + ) + + expect(clusters[0].position).toBe(0) + expect(clusters.at(-1)?.position).toBe(1) + expect(clusters[1].times).toEqual([150, 151, 152, 153]) + expect(clusters[1].events.map((event) => event.provider_event_id)).toEqual([ + 'E_tied_a', + 'E_tied_b', + 'E_dense_1', + 'E_dense_2', + 'E_dense_3', + ]) + }) +}) diff --git a/web/src/lib/playback.ts b/web/src/lib/playback.ts new file mode 100644 index 0000000..b3d1ba0 --- /dev/null +++ b/web/src/lib/playback.ts @@ -0,0 +1,143 @@ +import type { HistoryEvent } from './history' + +export const PLAYBACK_SPEEDS = [0.5, 1, 2, 4] as const +export type PlaybackSpeed = (typeof PLAYBACK_SPEEDS)[number] + +export interface PlaybackFrame { + playhead: number | null + playing: boolean + crossed: HistoryEvent[] +} + +export interface EventTickCluster { + position: number + times: number[] + events: HistoryEvent[] +} + +const FULL_HISTORY_DURATION_MS = 30_000 + +export class PlaybackClock { + readonly #start: number + readonly #end: number + readonly #events: HistoryEvent[] + #speed: PlaybackSpeed + #playhead: number + #lastAt = 0 + #playing = false + + constructor( + start: number, + end: number, + events: HistoryEvent[], + speed: PlaybackSpeed = 1, + ) { + this.#start = Math.min(start, end) + this.#end = Math.max(start, end) + this.#playhead = this.#start + this.#events = [...events].sort(compareEvents) + this.#speed = speed + } + + play(playhead: number | null, monotonicNow: number): PlaybackFrame { + this.#playhead = + playhead === null || playhead >= this.#end + ? this.#start + : clamp(playhead, this.#start, this.#end) + this.#lastAt = monotonicNow + this.#playing = this.#end > this.#start + return { + playhead: this.#playing ? this.#playhead : null, + playing: this.#playing, + crossed: this.#events.filter((event) => event.occurred_at === this.#playhead), + } + } + + pause(): PlaybackFrame { + this.#playing = false + return { playhead: this.#playhead, playing: false, crossed: [] } + } + + tick(monotonicNow: number): PlaybackFrame { + if (!this.#playing) { + return { playhead: this.#playhead, playing: false, crossed: [] } + } + + const elapsed = Math.max(0, monotonicNow - this.#lastAt) + this.#lastAt = monotonicNow + const previous = this.#playhead + const calendarPerMillisecond = (this.#end - this.#start) / FULL_HISTORY_DURATION_MS + this.#playhead = Math.min( + this.#end, + previous + elapsed * calendarPerMillisecond * this.#speed, + ) + const crossed = this.#events.filter( + (event) => event.occurred_at > previous && event.occurred_at <= this.#playhead, + ) + + if (this.#playhead >= this.#end) { + this.#playing = false + return { playhead: null, playing: false, crossed } + } + return { playhead: this.#playhead, playing: true, crossed } + } + + setSpeed(speed: PlaybackSpeed, monotonicNow: number): PlaybackFrame { + const frame = this.tick(monotonicNow) + this.#speed = speed + return frame + } +} + +export function distinctEventTimes(events: HistoryEvent[]): number[] { + return [...new Set(events.map((event) => event.occurred_at))].sort((a, b) => a - b) +} + +export function clusterEventTicks( + events: HistoryEvent[], + start: number, + end: number, + width: number, + clusterDistance = 10, +): EventTickCluster[] { + if (events.length === 0) return [] + const span = Math.max(1, end - start) + const byTime = new Map() + for (const event of [...events].sort(compareEvents)) { + const group = byTime.get(event.occurred_at) ?? [] + group.push(event) + byTime.set(event.occurred_at, group) + } + + const clusters: EventTickCluster[] = [] + let previousTickPosition: number | null = null + for (const [time, groupedEvents] of [...byTime].sort(([a], [b]) => a - b)) { + const position = clamp((time - start) / span, 0, 1) + const previous = clusters.at(-1) + if ( + previous && + previousTickPosition !== null && + (position - previousTickPosition) * Math.max(1, width) < clusterDistance + ) { + const count = previous.times.length + previous.position = (previous.position * count + position) / (count + 1) + previous.times.push(time) + previous.events.push(...groupedEvents) + } else { + clusters.push({ position, times: [time], events: [...groupedEvents] }) + } + previousTickPosition = position + } + return clusters +} + +export function compareEvents(left: HistoryEvent, right: HistoryEvent): number { + return ( + left.occurred_at - right.occurred_at || + left.provider_event_id.localeCompare(right.provider_event_id) + ) +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} diff --git a/web/src/lib/starmap/adapt.test.ts b/web/src/lib/starmap/adapt.test.ts index c6a5ce5..c5433fc 100644 --- a/web/src/lib/starmap/adapt.test.ts +++ b/web/src/lib/starmap/adapt.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { SpaceModel, Status } from '../model' +import { projectTemporalSpace, type HistoryEvent } from '../history' import { edgesOf } from './layout' import { toRendererModel } from './adapt' @@ -35,6 +36,11 @@ describe('toRendererModel', () => { it('maps every issue status and directs blocker edges toward the blocked issue', () => { const input = space() input.stars[0].parent_issue = 16 + input.stars[0].labels = ['IN-PROGRESS'] + input.stars[1].labels = ['in-progress'] + input.stars[1].assignees = ['ada'] + input.stars[3].labels = ['ready-for-agent'] + input.stars[4].labels = ['ready-for-agent'] const model = toRendererModel(input) @@ -49,6 +55,20 @@ describe('toRendererModel', () => { }) expect(model[1].parentIssue).toBeNull() expect(edgesOf(model)).toContainEqual({ from: 1, to: 5 }) + expect(model.map((ticket) => ticket.workPriority)).toEqual([ + 'in_progress', + 'terminal', + 'in_progress', + 'terminal', + 'blocked', + ]) + expect(model.map((ticket) => ticket.readyForAgent)).toEqual([ + false, + false, + false, + false, + false, + ]) }) it('marks only an unblocked ready-for-agent issue as actionable', () => { @@ -66,6 +86,13 @@ describe('toRendererModel', () => { false, false, ]) + expect(model.map((ticket) => ticket.workPriority)).toEqual([ + 'ready', + 'frontier', + 'in_progress', + 'terminal', + 'blocked', + ]) }) it('matches viewer ownership case-insensitively without redefining claimed', () => { @@ -100,4 +127,39 @@ describe('toRendererModel', () => { readyForAgent: true, }) }) + + it('passes temporal milestone membership to the renderer', () => { + const input = space() + input.stars[0].milestone = 'Launch ' + + const model = toRendererModel(input) + + expect(model[0].milestone).toBe('Launch ') + }) + + it('removes live workflow emphasis while preserving current dependency context', () => { + const input = space() + input.stars[0].labels = ['ready-for-agent'] + const events: HistoryEvent[] = input.stars.map((star, index) => ({ + sequence: index + 1, + repository_id: 'R_repo', + issue_id: `I_${star.number}`, + issue_number: star.number, + provider_event_id: `I_${star.number}:issue_created`, + occurred_at: 100, + kind: 'issue_created', + milestone: null, + })) + + const model = toRendererModel(projectTemporalSpace(input, events, 100)) + + expect(model[0]).toMatchObject({ + status: 'open', + frontier: false, + readyForAgent: false, + focusStatus: 'open', + historical: true, + }) + expect(edgesOf(model)).toContainEqual({ from: 1, to: 5 }) + }) }) diff --git a/web/src/lib/starmap/adapt.ts b/web/src/lib/starmap/adapt.ts index d57c74a..c118444 100644 --- a/web/src/lib/starmap/adapt.ts +++ b/web/src/lib/starmap/adapt.ts @@ -1,12 +1,20 @@ import type { SpaceModel } from '../model' +import type { TemporalSpace } from '../history' import type { Ticket } from './model' +import { deriveWorkPriority } from './work-priority' -export function toRendererModel(space: SpaceModel): Ticket[] { +export function toRendererModel(space: SpaceModel | TemporalSpace): Ticket[] { + const historical = 'temporal_active' in space && space.temporal_active const viewerLogin = space.viewer_login?.toLowerCase() return space.stars.map((star) => { - const legacyReadyForAgent = - star.status === 'frontier' && - star.labels.some((label) => label.toLowerCase() === 'ready-for-agent') + const liveStatus = 'live_status' in star ? star.live_status : star.status + const workPriority = historical + ? undefined + : deriveWorkPriority({ + status: liveStatus, + labels: star.labels, + assignees: star.assignees, + }) return { num: star.number, slug: String(star.number), @@ -15,12 +23,19 @@ export function toRendererModel(space: SpaceModel): Ticket[] { status: star.status, blockedBy: [...star.blocked_by], parentIssue: star.parent_issue, - frontier: star.status === 'frontier', - readyForAgent: star.ready_for_agent ?? legacyReadyForAgent, - blocked: star.blocked ?? star.status === 'blocked', + frontier: !historical && liveStatus === 'frontier', + readyForAgent: + !historical && (star.ready_for_agent === true || workPriority === 'ready'), + workPriority, + blocked: !historical && (star.blocked ?? liveStatus === 'blocked'), assignedToViewer: + !historical && viewerLogin !== undefined && star.assignees.some((assignee) => assignee.toLowerCase() === viewerLogin), + visible: 'temporal_visible' in star ? star.temporal_visible : true, + focusStatus: historical ? star.status : liveStatus, + milestone: star.milestone, + historical, } }) } diff --git a/web/src/lib/starmap/core-visual.test.ts b/web/src/lib/starmap/core-visual.test.ts index 93c4ad8..85478cd 100644 --- a/web/src/lib/starmap/core-visual.test.ts +++ b/web/src/lib/starmap/core-visual.test.ts @@ -1,149 +1,86 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { StarMap } from './starmap' import type { Ticket } from './model' -import type { SessionState } from './session' -type Arc = { radius: number } -type Gradient = { kind: 'radial-gradient'; stops: Array<{ at: number; color: string }> } -type Fill = { style: string | Gradient; arc: Arc | undefined } -type Stroke = { style: string | Gradient; lineWidth: number; arc: Arc | undefined } -type ArcCenter = { x: number; y: number; radius: number } +type Arc = { x: number; y: number; radius: number } +type Gradient = { + kind: 'radial-gradient' + origin: { x0: number; y0: number; r0: number; x1: number; y1: number; r1: number } + stops: Array<{ at: number; color: string }> +} +type Fill = { kind: 'fill'; style: string | Gradient; arc: Arc | undefined } +type Stroke = { kind: 'stroke'; style: string | Gradient; lineWidth: number; arc: Arc | undefined } +type Paint = Fill | Stroke const RESOLVED: Ticket = { - num: 1, - slug: '1', - title: 'Resolved core', - type: 'issue', - status: 'resolved', - frontier: false, - blockedBy: [], - parentIssue: null, + num: 1, slug: '1', title: 'Resolved', type: 'issue', status: 'resolved', + frontier: false, blockedBy: [], parentIssue: null, workPriority: 'terminal', } -const FRONTIER: Ticket = { - num: 2, - slug: '2', - title: 'Frontier core', - type: 'issue', - status: 'open', - frontier: true, - blockedBy: [], - parentIssue: null, +const IN_PROGRESS: Ticket = { + num: 2, slug: '2', title: 'In progress', type: 'issue', status: 'open', + frontier: false, blockedBy: [], parentIssue: null, workPriority: 'in_progress', } -const CLAIMED: Ticket = { - num: 3, - slug: '3', - title: 'Claimed core', - type: 'issue', - status: 'claimed', - frontier: false, - blockedBy: [], - parentIssue: null, +const READY: Ticket = { + num: 3, slug: '3', title: 'Ready', type: 'issue', status: 'open', + frontier: true, blockedBy: [], parentIssue: null, readyForAgent: true, workPriority: 'ready', } const BLOCKED: Ticket = { - num: 4, - slug: '4', - title: 'Blocked core', - type: 'issue', - status: 'open', - frontier: false, - blockedBy: [1], - parentIssue: null, -} -const OUT_OF_SCOPE: Ticket = { - num: 5, - slug: '5', - title: 'Out of scope core', - type: 'issue', - status: 'out_of_scope', - frontier: false, - blockedBy: [], - parentIssue: null, -} -const INCOMPLETE_CHILD: Ticket = { ...BLOCKED, num: 6, slug: '6', parentIssue: 99 } -const RESOLVED_CHILD: Ticket = { ...RESOLVED, num: 7, slug: '7', parentIssue: 99 } -const OUT_OF_SCOPE_CHILD: Ticket = { ...OUT_OF_SCOPE, num: 9, slug: '9', parentIssue: 99 } -const READY_CHILD: Ticket = { - ...FRONTIER, - num: 8, - slug: '8', - parentIssue: 99, - readyForAgent: true, -} -const MY_NEXT: Ticket = { - ...CLAIMED, - num: 10, - slug: '10', - title: 'My next work', - readyForAgent: true, - assignedToViewer: true, -} -const MY_FUTURE: Ticket = { - ...CLAIMED, - num: 11, - slug: '11', - title: 'My future work', - assignedToViewer: true, -} -const AVAILABLE_NEXT: Ticket = { - ...FRONTIER, - num: 12, - slug: '12', - title: 'Available next', - readyForAgent: true, + num: 4, slug: '4', title: 'Blocked', type: 'issue', status: 'open', + frontier: false, blockedBy: [], parentIssue: null, workPriority: 'blocked', } +const READY_CHILD: Ticket = { ...READY, num: 5, slug: '5', parentIssue: 99 } -function recordingContext(): { - ctx: Record - fills: Fill[] - strokes: Stroke[] - arcs: ArcCenter[] -} { - const fills: Fill[] = [] - const strokes: Stroke[] = [] - const arcs: ArcCenter[] = [] +function recordingContext() { + const paints: Paint[] = [] + const texts: string[] = [] let arc: Arc | undefined const ctx: Record = { - createRadialGradient: () => { - const gradient: Gradient = { kind: 'radial-gradient', stops: [] } + createRadialGradient: ( + x0: number, y0: number, r0: number, x1: number, y1: number, r1: number, + ) => { + const gradient: Gradient = { + kind: 'radial-gradient', + origin: { x0, y0, r0, x1, y1, r1 }, + stops: [], + } Object.defineProperty(gradient, 'addColorStop', { value: (at: number, color: string) => gradient.stops.push({ at, color }), }) return gradient }, - beginPath: () => { - arc = undefined - }, - arc: (x: number, y: number, radius: number) => { - arc = { radius } - arcs.push({ x, y, radius }) - }, - fill: () => fills.push({ style: ctx.fillStyle as string | Gradient, arc }), - stroke: () => - strokes.push({ - style: ctx.strokeStyle as string | Gradient, - lineWidth: ctx.lineWidth as number, - arc, - }), + beginPath: () => { arc = undefined }, + arc: (x: number, y: number, radius: number) => { arc = { x, y, radius } }, + fill: () => paints.push({ kind: 'fill', style: ctx.fillStyle as string | Gradient, arc }), + stroke: () => paints.push({ + kind: 'stroke', + style: ctx.strokeStyle as string | Gradient, + lineWidth: ctx.lineWidth as number, + arc, + }), measureText: () => ({ width: 40 }), fillRect: () => {}, - fillText: () => {}, + fillText: (text: string) => texts.push(text), } for (const method of [ - 'setTransform', - 'moveTo', - 'lineTo', - 'closePath', - 'quadraticCurveTo', - 'setLineDash', - 'save', - 'restore', - 'translate', - 'scale', - 'rotate', - ]) { - ctx[method] = () => {} - } - return { ctx, fills, strokes, arcs } + 'setTransform', 'moveTo', 'lineTo', 'closePath', 'quadraticCurveTo', 'setLineDash', + 'save', 'restore', 'translate', 'scale', 'rotate', + ]) ctx[method] = () => {} + return { ctx, paints, texts } +} + +function bodyFill(paints: Paint[]): Fill { + return paints.find((paint): paint is Fill => + paint.kind === 'fill' && + typeof paint.style !== 'string' && + paint.style.stops.some((stop) => stop.at === 0.48), + )! +} + +function outerRings(paints: Paint[]): Stroke[] { + const body = bodyFill(paints) + return paints.filter((paint): paint is Stroke => + paint.kind === 'stroke' && (paint.arc?.radius ?? 0) > body.arc!.radius, + ) } describe('issue core visual grammar', () => { @@ -172,15 +109,8 @@ describe('issue core visual grammar', () => { function paint( ticket: Ticket, currentIssue: number | null = null, - session: SessionState | null = null, - options: { scale?: number; timeMs?: number; reducedMotion?: boolean } = {}, - ): { fills: Fill[]; strokes: Stroke[]; arcs: ArcCenter[] } { - now.mockReturnValue(options.timeMs ?? 0) - vi.stubGlobal('matchMedia', () => ({ - matches: options.reducedMotion ?? false, - addEventListener: () => {}, - removeEventListener: () => {}, - })) + selectedIssue: number | null = null, + ) { const recording = recordingContext() getContext.mockReturnValue(recording.ctx as never) const host = document.createElement('div') @@ -189,10 +119,8 @@ describe('issue core visual grammar', () => { document.body.appendChild(host) const map = new StarMap() map.mount(host) - map.setModel([ticket], session ? { [ticket.num]: session } : {}, currentIssue) - if (options.scale !== undefined) { - map.restoreCamera({ x: 500, y: 350, s: options.scale }) - } + map.setModel([ticket], {}, currentIssue) + if (selectedIssue !== null) map.select(selectedIssue) const frame = frames.pop() frames = [] frame?.(0) @@ -200,214 +128,98 @@ describe('issue core visual grammar', () => { return recording } - it('keeps resolved as the existing solid radial-gradient core without a black disk', () => { - const { fills } = paint(RESOLVED) - - expect(fills).toHaveLength(2) - expect(fills[1]).toEqual({ - style: { - kind: 'radial-gradient', - stops: [ - { at: 0, color: 'rgba(185,214,196,1)' }, - { at: 0.6, color: 'rgba(185,214,196,0.92)' }, - { at: 0.82, color: 'rgba(185,214,196,0.45)' }, - { at: 1, color: 'rgba(185,214,196,0)' }, - ], - }, - arc: { radius: 9.1125 }, - }) - expect(fills.some((fill) => fill.style === '#000')).toBe(false) - }) - - it('paints My next work as a solid blue core without a black disk', () => { - const { fills, strokes } = paint(MY_NEXT) - - expect(fills[1]).toEqual({ style: '#8ad8ff', arc: { radius: 11 } }) - expect(fills.some((fill) => fill.style === '#000')).toBe(false) - expect(strokes.some((stroke) => stroke.style === 'rgba(138,216,255,0.95)')).toBe(false) - }) - - it('paints doing now and team work as distinct solid cores', () => { - const doing = paint(BLOCKED, BLOCKED.num, null, { reducedMotion: true }) - expect(doing.fills[1]).toEqual({ style: '#ffd873', arc: { radius: 12 } }) - expect(doing.fills.some((fill) => fill.style === '#000')).toBe(false) - - const team = paint(CLAIMED) - expect(team.fills[1]).toEqual({ style: '#b9a7ee', arc: { radius: 9 } }) - expect(team.fills.some((fill) => fill.style === '#000')).toBe(false) + it('layers glow, contact shadow, body, specular, and an internal boundary', () => { + const { paints } = paint(READY) + const body = bodyFill(paints) + const bodyIndex = paints.indexOf(body) + const glow = paints[0] as Fill + const shadow = paints[1] as Fill + const specular = paints[bodyIndex + 1] as Fill + const boundary = paints[bodyIndex + 2] as Stroke + + expect([glow.kind, shadow.kind, body.kind, specular.kind, boundary.kind]).toEqual([ + 'fill', 'fill', 'fill', 'fill', 'stroke', + ]) + expect((body.style as Gradient).stops).toEqual([ + { at: 0, color: 'rgba(138,216,255,1)' }, + { at: 0.48, color: 'rgba(138,216,255,0.98)' }, + { at: 0.82, color: 'rgba(47,155,224,0.92)' }, + { at: 1, color: 'rgba(47,155,224,0.62)' }, + ]) + expect((body.style as Gradient).origin.x0).toBeLessThan(body.arc!.x) + expect((body.style as Gradient).origin.y0).toBeLessThan(body.arc!.y) + expect(shadow.arc!.y).toBeGreaterThan(body.arc!.y) + expect(specular.arc!.x).toBeLessThan(body.arc!.x) + expect(specular.arc!.y).toBeLessThan(body.arc!.y) + expect(specular.arc!.radius).toBeLessThan(body.arc!.radius / 3) + expect(boundary.arc!.radius).toBeLessThan(body.arc!.radius) }) - it('uses the doing-now amber core size when a session needs attention', () => { - const attention = paint(CLAIMED, null, 'blocked') - - expect(attention.fills[1]).toEqual({ style: '#ffd873', arc: { radius: 12 } }) - expect(attention.fills.some((fill) => fill.style === '#000')).toBe(false) + it('uses at most one status ring and one selection ring', () => { + expect(outerRings(paint(IN_PROGRESS).paints)).toHaveLength(1) + expect(outerRings(paint(READY_CHILD).paints)).toHaveLength(1) + expect(outerRings(paint(BLOCKED).paints)).toHaveLength(0) + expect(outerRings(paint(RESOLVED).paints)).toHaveLength(0) + expect(outerRings(paint({ ...BLOCKED, parentIssue: 99 }).paints)).toHaveLength(0) + expect(outerRings(paint(READY_CHILD, null, READY_CHILD.num).paints)).toHaveLength(2) + expect(outerRings(paint(BLOCKED, null, BLOCKED.num).paints)).toHaveLength(1) }) - it('keeps active priorities readable with screen-space radius floors', () => { - for (const expected of [ - { ticket: BLOCKED, current: BLOCKED.num, radius: 50 }, - { ticket: MY_NEXT, current: null, radius: 42.5 }, - { ticket: MY_FUTURE, current: null, radius: 35 }, - { ticket: AVAILABLE_NEXT, current: null, radius: 35 }, - { ticket: CLAIMED, current: null, radius: 30 }, - { ticket: OUT_OF_SCOPE, current: null, radius: 5.625 }, - ]) { - const { fills } = paint(expected.ticket, expected.current, null, { - scale: 0.2, - reducedMotion: true, - }) - expect(fills[1].arc?.radius).toBe(expected.radius) - } + it('keeps CURRENT as label semantics without drawing CURRENT rings', () => { + const { paints, texts } = paint(BLOCKED, BLOCKED.num) - const session = paint(CLAIMED, null, 'implementing', { - scale: 0.2, - reducedMotion: true, - }) - expect(session.arcs).toContainEqual(expect.objectContaining({ radius: 60 })) + expect(outerRings(paints)).toHaveLength(0) + expect(texts.some((text) => text.startsWith('CURRENT'))).toBe(true) }) - it('pulses only doing-now cores from 1.00 to 1.08 and freezes under reduced motion', () => { - const lowMs = ((Math.PI * 1.5) / 2.8) * 1000 - const highMs = ((Math.PI * 0.5) / 2.8) * 1000 - - expect(paint(BLOCKED, BLOCKED.num, null, { timeMs: lowMs }).fills[1].arc?.radius).toBeCloseTo(12) - expect(paint(BLOCKED, BLOCKED.num, null, { timeMs: highMs }).fills[1].arc?.radius).toBeCloseTo(12.96) - expect(paint(MY_NEXT, null, null, { timeMs: highMs }).fills[1].arc?.radius).toBe(11) - expect( - paint(BLOCKED, BLOCKED.num, null, { timeMs: highMs, reducedMotion: true }).fills[1].arc?.radius, - ).toBe(12) - }) + it('does not turn a selected state-change flare into another ring', () => { + const recording = recordingContext() + getContext.mockReturnValue(recording.ctx as never) + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { value: 1000 }) + Object.defineProperty(host, 'clientHeight', { value: 700 }) + document.body.appendChild(host) + const map = new StarMap() + map.mount(host) + map.setModel([BLOCKED]) + map.setModel([{ ...BLOCKED, workPriority: 'in_progress' }]) + map.select(BLOCKED.num) + now.mockReturnValue(16) + const frame = frames.pop() + frames = [] + frame?.(0) - it('freezes the existing session proton under reduced motion', () => { - const moon = (timeMs: number, reducedMotion: boolean) => - paint(CLAIMED, null, 'implementing', { timeMs, reducedMotion }).arcs.find( - (arc) => arc.radius === 2.7, - ) + const rings = outerRings(recording.paints) + const bodyRadius = bodyFill(recording.paints).arc!.radius + map.destroy() - expect(moon(0, false)).not.toEqual(moon(1000, false)) - expect(moon(0, true)).toEqual(moon(1000, true)) + expect(rings).toHaveLength(2) + expect(Math.min(...rings.map((ring) => ring.arc!.radius))).toBeGreaterThanOrEqual(bodyRadius + 7) }) - it('paints future, available, planning, and not-planned work as hollow priority cores', () => { - for (const expected of [ - { - ticket: MY_FUTURE, - glow: [ - { at: 0, color: 'rgba(47,155,224,0.85)' }, - { at: 0.4, color: 'rgba(47,155,224,0.22)' }, - { at: 1, color: 'rgba(47,155,224,0)' }, - ], - blackRadius: 10, - rim: 'rgba(138,216,255,0.95)', - width: 3.2, - }, - { - ticket: AVAILABLE_NEXT, - glow: [ - { at: 0, color: 'rgba(59,159,104,0.85)' }, - { at: 0.4, color: 'rgba(59,159,104,0.22)' }, - { at: 1, color: 'rgba(59,159,104,0)' }, - ], - blackRadius: 10, - rim: 'rgba(142,215,172,0.95)', - width: 3.2, - }, - { - ticket: BLOCKED, - glow: [ - { at: 0, color: 'rgba(113,104,132,0.85)' }, - { at: 0.4, color: 'rgba(113,104,132,0.22)' }, - { at: 1, color: 'rgba(113,104,132,0)' }, - ], - blackRadius: 5.625, - rim: 'rgba(170,160,189,0.95)', - width: 2.2, - }, - { - ticket: OUT_OF_SCOPE, - glow: [ - { at: 0, color: 'rgba(107,100,120,0.85)' }, - { at: 0.4, color: 'rgba(107,100,120,0.22)' }, - { at: 1, color: 'rgba(107,100,120,0)' }, - ], - blackRadius: 5.625, - rim: 'rgba(148,141,164,0.95)', - width: 2.2, - }, - ]) { - const { fills, strokes } = paint(expected.ticket) - const glow = fills[0].style as Gradient - - expect(fills).toHaveLength(2) - expect(glow.kind).toBe('radial-gradient') - expect(glow.stops).toEqual(expected.glow) - expect(fills[1]).toEqual({ style: '#000', arc: { radius: expected.blackRadius } }) - expect(fills.filter((fill) => fill.style === '#000')).toHaveLength(1) - expect(fills.filter((fill) => typeof fill.style !== 'string')).toHaveLength(1) - expect(strokes).toContainEqual({ - style: expected.rim, - lineWidth: expected.width, - arc: { radius: expected.blackRadius - expected.width / 2 }, - }) + it('holds animated glow geometry and alpha still under reduced motion', () => { + const recording = recordingContext() + getContext.mockReturnValue(recording.ctx as never) + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { value: 1000 }) + Object.defineProperty(host, 'clientHeight', { value: 700 }) + document.body.appendChild(host) + const map = new StarMap() + map.setReducedMotion(true) + map.mount(host) + map.setModel([BLOCKED]) + map.setModel([{ ...BLOCKED, workPriority: 'in_progress' }]) + + const takeGlow = () => { + recording.paints.length = 0 + const frame = frames.shift() + frame?.(0) + return structuredClone(recording.paints[0]) } - }) - - it('keeps both CURRENT rings around the solid doing-now core', () => { - const { fills, strokes } = paint(BLOCKED, BLOCKED.num, null, { reducedMotion: true }) - - expect(fills).toHaveLength(2) - expect(fills[1]).toEqual({ style: '#ffd873', arc: { radius: 12 } }) - expect(strokes.map((stroke) => ({ style: stroke.style, lineWidth: stroke.lineWidth, radius: stroke.arc?.radius }))).toEqual([ - { style: 'rgba(255,255,255,0.95)', lineWidth: 2, radius: 20 }, - { style: 'rgba(255,255,255,0.55)', lineWidth: 1, radius: 25 }, - ]) - }) - - it('adds one relationship rim outside an incomplete child core and none to a resolved child', () => { - const incomplete = paint(INCOMPLETE_CHILD) - const relationshipRims = incomplete.strokes.filter((stroke) => stroke.style === 'rgba(170,145,255,0.82)') - const blackCore = incomplete.fills.find((fill) => fill.style === '#000')! - - expect(relationshipRims).toHaveLength(1) - expect(relationshipRims[0].arc!.radius).toBeGreaterThan(blackCore.arc!.radius) - expect(incomplete.strokes).toContainEqual({ - style: 'rgba(170,160,189,0.95)', - lineWidth: 2.2, - arc: { radius: 4.525 }, - }) - expect(paint(RESOLVED_CHILD).strokes.some((stroke) => stroke.style === 'rgba(170,145,255,0.82)')).toBe(false) - expect(paint(OUT_OF_SCOPE_CHILD).strokes.some((stroke) => stroke.style === 'rgba(170,145,255,0.82)')).toBe(false) - }) - - it('keeps READY and CURRENT emphasis strokes dominant over the relationship rim', () => { - const ready = paint(READY_CHILD) - const readyStyles = ready.strokes.map((stroke) => stroke.style) - const readyRelationshipIndex = readyStyles.indexOf('rgba(170,145,255,0.82)') - const readyRelationshipRadius = ready.strokes[readyRelationshipIndex].arc!.radius - const readyEmphasisIndexes = ready.strokes - .map((stroke, index) => - stroke.style === 'rgba(142,215,172,0.95)' && - (stroke.arc?.radius ?? 0) > readyRelationshipRadius - ? index - : -1, - ) - .filter((index) => index >= 0) - expect(readyRelationshipIndex).toBeGreaterThanOrEqual(0) - expect(readyEmphasisIndexes).toHaveLength(1) - expect(readyEmphasisIndexes[0]).toBeGreaterThan(readyRelationshipIndex) - const current = paint(INCOMPLETE_CHILD, INCOMPLETE_CHILD.num) - const currentStyles = current.strokes.map((stroke) => stroke.style) - const relationshipIndex = currentStyles.indexOf('rgba(170,145,255,0.82)') - const whiteRingIndexes = currentStyles - .map((style, index) => typeof style === 'string' && style.startsWith('rgba(255,255,255,') ? index : -1) - .filter((index) => index >= 0) - expect(whiteRingIndexes).toHaveLength(2) - expect(whiteRingIndexes.every((index) => index > relationshipIndex)).toBe(true) - const relationshipRadius = current.strokes[relationshipIndex].arc!.radius - for (const index of whiteRingIndexes) { - expect(current.strokes[index].arc!.radius).toBeGreaterThan(relationshipRadius) - } + const first = takeGlow() + const second = takeGlow() + map.destroy() + expect(second).toEqual(first) }) }) diff --git a/web/src/lib/starmap/edge-visual.test.ts b/web/src/lib/starmap/edge-visual.test.ts index 7e91793..29b0bc1 100644 --- a/web/src/lib/starmap/edge-visual.test.ts +++ b/web/src/lib/starmap/edge-visual.test.ts @@ -80,7 +80,7 @@ const SELECTION_FIXTURE: Ticket[] = [ function edgeStrokes(render: { strokes: Stroke[] }): Stroke[] { return render.strokes.filter((stroke) => - ['rgba(150,178,160,0.36)', 'rgba(174,192,218,0.62)', 'rgba(170,145,255,0.78)'].includes(stroke.color), + ['rgba(190,225,200,0.82)', 'rgba(174,192,218,0.62)', 'rgba(170,145,255,0.78)'].includes(stroke.color), ) } @@ -105,14 +105,14 @@ describe('dependency-edge visual treatment', () => { const realCancelRaf = globalThis.cancelAnimationFrame let frames: FrameRequestCallback[] = [] - function installFrameHarness(): ReturnType { + function installFrameHarness(): void { frames = [] globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { frames.push(cb) return frames.length }) as never globalThis.cancelAnimationFrame = (() => {}) as never - return vi.spyOn(performance, 'now').mockReturnValue(1_000) + vi.spyOn(performance, 'now').mockReturnValue(1_000) } function paintFrames( @@ -174,21 +174,23 @@ describe('dependency-edge visual treatment', () => { } function expectParticleMotion(render: { fills: Fill[] }, edgeAlpha: number, edgePhase = 0.27): void { - const particles = render.fills.filter((fill) => - typeof fill.color === 'string' && - fill.color.startsWith('rgba(190,218,198,') && - fill.arcs[0]?.radius === 1.8, - ) - expect(particles).toHaveLength(2) - for (let index = 0; index < 2; index++) { - const u = (0.1 + index / 2 + edgePhase) % 1 - const expectedAlpha = 0.35 + 0.4 * Math.sin(Math.PI * u) - const particle = particles[index] - const particleAlpha = Number( - (particle.color as string).slice((particle.color as string).lastIndexOf(',') + 1, -1), - ) - expect(particle.alpha).toBe(edgeAlpha) - expect(particleAlpha * particle.alpha).toBeCloseTo(expectedAlpha * edgeAlpha) + const halos = render.fills.filter((fill) => typeof fill.color === 'string' && fill.color.startsWith('rgba(190,225,200,') && fill.arcs[0]?.radius === 5) + const cores = render.fills.filter((fill) => typeof fill.color === 'string' && fill.color.startsWith('rgba(220,255,230,') && fill.arcs[0]?.radius === 2.6) + expect(halos).toHaveLength(3) + expect(cores).toHaveLength(3) + for (let index = 0; index < 3; index++) { + const u = (0.1 + index / 3 + edgePhase) % 1 + const expectedHalo = 0.14 + 0.18 * Math.sin(Math.PI * u) + const expectedCore = 0.45 + 0.5 * Math.sin(Math.PI * u) + const halo = halos[index] + const core = cores[index] + const haloAlpha = Number((halo.color as string).slice((halo.color as string).lastIndexOf(',') + 1, -1)) + const coreAlpha = Number((core.color as string).slice((core.color as string).lastIndexOf(',') + 1, -1)) + expect(halo.alpha).toBe(edgeAlpha) + expect(core.alpha).toBe(edgeAlpha) + expect(haloAlpha * halo.alpha).toBeCloseTo(expectedHalo * edgeAlpha) + expect(coreAlpha * core.alpha).toBeCloseTo(expectedCore * edgeAlpha) + expect(core.arcs[0]).toMatchObject({ x: halo.arcs[0].x, y: halo.arcs[0].y }) } } @@ -197,7 +199,6 @@ describe('dependency-edge visual treatment', () => { globalThis.requestAnimationFrame = realRaf globalThis.cancelAnimationFrame = realCancelRaf vi.restoreAllMocks() - vi.unstubAllGlobals() document.body.replaceChildren() }) @@ -207,21 +208,18 @@ describe('dependency-edge visual treatment', () => { const focused = paint(EDGE_FIXTURE, 2) const { strokes, fills } = focused - const resolved = strokes.find((stroke) => stroke.color === 'rgba(150,178,160,0.36)')! + const resolved = strokes.find((stroke) => stroke.color === 'rgba(190,225,200,0.82)')! const unresolved = strokes.find((stroke) => stroke.color === 'rgba(174,192,218,0.62)')! - expect(resolved).toMatchObject({ width: 1.6, dash: [], cap: 'round', alpha: 1 }) + expect(resolved).toMatchObject({ width: 3, dash: [], cap: 'round', alpha: 1 }) expect(unresolved).toMatchObject({ width: 2.4, dash: [7, 7], cap: 'round', alpha: 0.45 }) - const resolvedArrow = fills.find((fill) => fill.color === 'rgba(190,218,198,0.52)')! + const resolvedArrow = fills.find((fill) => fill.color === '#d9f3df')! const unresolvedArrow = fills.find((fill) => fill.color === '#c8d5e8')! expect(resolvedArrow.alpha).toBe(1) expect(unresolvedArrow.alpha).toBe(0.45) for (const arrow of [resolvedArrow, unresolvedArrow]) { expect(arrow.points).toHaveLength(3) - const [tip, baseA, baseB] = arrow.points - const base = { x: (baseA.x + baseB.x) / 2, y: (baseA.y + baseB.y) / 2 } - expect(Math.hypot(tip.x - base.x, tip.y - base.y)).toBeCloseTo(12) - expect(Math.hypot(baseA.x - base.x, baseA.y - base.y)).toBeCloseTo(6.5) + expectArrowDimensions(arrow, 8, 4) } expectParticleMotion(focused, 1) @@ -231,7 +229,7 @@ describe('dependency-edge visual treatment', () => { // Current issue 4 has no ready-to-current path: both edges are context. // This makes the resolved 1 → 2 particle flow itself prove the multiplier. - expect(paint(EDGE_FIXTURE, 4).fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) + expectParticleMotion(paint(EDGE_FIXTURE, 4), 0.45) }) it('scopes selection emphasis to direct dependency edges and restores ordinary treatment', () => { @@ -245,12 +243,12 @@ describe('dependency-edge visual treatment', () => { expect(selectedEdges.map(({ color, width, alpha }) => ({ color, width, alpha }))).toEqual([ { color: 'rgba(174,192,218,0.62)', width: 2.4, alpha: 0.45 }, { color: 'rgba(174,192,218,0.62)', width: 2.4, alpha: 0.45 }, - { color: 'rgba(150,178,160,0.36)', width: 2.72, alpha: 1 }, + { color: 'rgba(190,225,200,0.82)', width: 5.1, alpha: 1 }, { color: 'rgba(174,192,218,0.62)', width: 4.08, alpha: 1 }, ]) expect( - selectedEdges.find((stroke) => stroke.color === 'rgba(150,178,160,0.36)'), - ).toMatchObject({ width: 2.72, dash: [], alpha: 1 }) + selectedEdges.find((stroke) => stroke.color === 'rgba(190,225,200,0.82)'), + ).toMatchObject({ width: 5.1, dash: [], alpha: 1 }) const unresolvedEdges = selectedEdges.filter( (stroke) => stroke.color === 'rgba(174,192,218,0.62)', ) @@ -263,19 +261,25 @@ describe('dependency-edge visual treatment', () => { alpha: 0.45, }) - const resolvedArrow = selected.fills.find((fill) => fill.color === 'rgba(190,218,198,0.52)')! + const resolvedArrow = selected.fills.find((fill) => fill.color === '#d9f3df')! const unresolvedArrows = selected.fills.filter((fill) => fill.color === '#c8d5e8') expect(resolvedArrow.alpha).toBe(1) - expectArrowDimensions(resolvedArrow, 15, 8.125) + expectArrowDimensions(resolvedArrow, 8, 4) const selectedUnresolvedArrow = unresolvedArrows.find((fill) => fill.alpha === 1)! const contextUnresolvedArrow = unresolvedArrows.find((fill) => fill.alpha === 0.45)! - expectArrowDimensions(selectedUnresolvedArrow, 15, 8.125) - expectArrowDimensions(contextUnresolvedArrow, 12, 6.5) - expect(selected.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) + expectArrowDimensions(selectedUnresolvedArrow, 8, 4) + expectArrowDimensions(contextUnresolvedArrow, 8, 4) + expect(arrowDimensions(selectedUnresolvedArrow).length).toBeCloseTo( + arrowDimensions(contextUnresolvedArrow).length, + ) + expect(arrowDimensions(selectedUnresolvedArrow).halfWidth).toBeCloseTo( + arrowDimensions(contextUnresolvedArrow).halfWidth, + ) + expectParticleMotion(selected, 1) const deselected = edgeStrokes(deselectedRender) expect(deselected.map(({ width, alpha }) => ({ width, alpha }))).toEqual([ - { width: 1.6, alpha: 0.45 }, + { width: 3, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, @@ -296,123 +300,31 @@ describe('dependency-edge visual treatment', () => { ]) expect(edgeStrokes(restored).map(({ width, alpha }) => ({ width, alpha }))).toEqual([ - { width: 1.6, alpha: 0.45 }, + { width: 3, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, ]) }) - it('animates exactly two subtle particles on a traversed edge into available work', () => { + it('animates exactly three halo/core pairs on a traversed sequence edge at full path alpha', () => { installFrameHarness() expectParticleMotion(paint(sequenceFixture('resolved', 'frontier'), 16), 1, 0.47) }) - it('renders traversed history as a subtle static line unless it points into active work', () => { - installFrameHarness() - - const render = paint(sequenceFixture('resolved', 'blocked')) - const history = render.strokes.find( - (stroke) => stroke.color === 'rgba(150,178,160,0.36)', - )! - const arrow = render.fills.find( - (fill) => fill.color === 'rgba(190,218,198,0.52)', - )! - - expect(history).toMatchObject({ width: 1.6, dash: [], cap: 'round', alpha: 1 }) - expect(arrow).toBeDefined() - expect(render.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toHaveLength(0) - - for (const destination of [ - { status: 'claimed' as const, assignedToViewer: true }, - { status: 'claimed' as const, assignedToViewer: false }, - ]) { - const tickets = sequenceFixture('resolved', destination.status).map((ticket) => - ticket.num === 38 ? { ...ticket, ...destination } : ticket, - ) - expect(paint(tickets).fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) - } - }) - - it('animates exactly two small particles only into doing, my-next, and available work', () => { - installFrameHarness() - - const variants: Array<{ tickets: Ticket[]; current: number | null }> = [ - { tickets: sequenceFixture('resolved', 'blocked'), current: 38 }, - { - tickets: sequenceFixture('resolved', 'claimed').map((ticket) => - ticket.num === 38 - ? { ...ticket, readyForAgent: true, assignedToViewer: true } - : ticket, - ), - current: null, - }, - { tickets: sequenceFixture('resolved', 'frontier'), current: null }, - ] - - for (const variant of variants) { - const particles = paint(variant.tickets, variant.current).fills.filter( - (fill) => fill.arcs[0]?.radius === 1.8, - ) - expect(particles).toHaveLength(2) - for (const particle of particles) { - expect(particle.color).toMatch(/^rgba\(190,218,198,0\.[0-9]+\)$/) - } - } - }) - - it('keeps motion directional, direct, and independent from selection', () => { - installFrameHarness() - const chain: Ticket[] = [ - { num: 1, slug: '1', title: 'history', type: 'task', status: 'resolved', blockedBy: [], parentIssue: null, frontier: false }, - { num: 2, slug: '2', title: 'available', type: 'task', status: 'frontier', blockedBy: [1], parentIssue: null, frontier: true, readyForAgent: true }, - { num: 3, slug: '3', title: 'later', type: 'task', status: 'blocked', blockedBy: [2], parentIssue: null, frontier: false }, - ] - - expect(paint(chain).fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toHaveLength(2) - - const selectedStatic = paint(sequenceFixture('resolved', 'blocked'), null, [38]) - expect(selectedStatic.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toHaveLength(0) - expect( - selectedStatic.strokes.find( - (stroke) => stroke.color === 'rgba(150,178,160,0.36)' && stroke.width > 1.6, - ), - ).toMatchObject({ width: 2.72, alpha: 1 }) - }) - - it('freezes eligible edge particles when reduced motion is requested', () => { - const clock = installFrameHarness() - vi.stubGlobal('matchMedia', () => ({ - matches: true, - addEventListener: () => {}, - removeEventListener: () => {}, - })) - const [first, second] = paintFrames(sequenceFixture('resolved', 'frontier'), null, [ - () => clock.mockReturnValue(1_000), - () => clock.mockReturnValue(2_000), - ]) - const particlePositions = (render: { fills: Fill[] }) => - render.fills - .filter((fill) => fill.arcs[0]?.radius === 1.8) - .map((fill) => ({ x: fill.arcs[0].x, y: fill.arcs[0].y })) - - expect(particlePositions(first)).toHaveLength(2) - expect(particlePositions(second)).toEqual(particlePositions(first)) - }) - it('does not animate a resolved sequence source whose destination remains blocked', () => { installFrameHarness() const render = paint(sequenceFixture('resolved', 'blocked'), 16) - expect(render.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) + expect(render.fills.filter((fill) => [5, 2.6].includes(fill.arcs[0]?.radius ?? 0))).toEqual([]) }) it('does not animate an open sequence source whose destination is frontier', () => { installFrameHarness() const render = paint(sequenceFixture('open', 'frontier'), 16) - expect(render.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) + expect(render.fills.filter((fill) => [5, 2.6].includes(fill.arcs[0]?.radius ?? 0))).toEqual([]) }) it('multiplies contextual traversed sequence particles by context edge alpha', () => { @@ -455,6 +367,7 @@ describe('dependency-edge visual treatment', () => { expect(firstCurve.control.y + secondCurve.control.y).toBeCloseTo(sharedMidpoint.y * 2) for (let index = 0; index < violetArrows.length; index++) { + expectArrowDimensions(violetArrows[index], 8, 4) const [tip, baseA, baseB] = violetArrows[index].points const base = { x: (baseA.x + baseB.x) / 2, y: (baseA.y + baseB.y) / 2 } const stroke = violetStrokes[index] @@ -501,18 +414,19 @@ describe('dependency-edge visual treatment', () => { const selectedMiniArrows = selectedChild.fills.filter((fill) => fill.color === '#c7b8ff') expect(selectedMiniArrows).toHaveLength(2) for (const arrow of selectedMiniArrows) { - expectArrowDimensions(arrow, 15, 8.125) + expectArrowDimensions(arrow, 8, 4) } const completed = paintChild('resolved') - const mintStrokes = completed.strokes.filter((stroke) => stroke.color === 'rgba(150,178,160,0.36)') - const mintArrows = completed.fills.filter((fill) => fill.color === 'rgba(190,218,198,0.52)') + const mintStrokes = completed.strokes.filter((stroke) => stroke.color === 'rgba(190,225,200,0.82)') + const mintArrows = completed.fills.filter((fill) => fill.color === '#d9f3df') expect(mintStrokes).toHaveLength(2) expect(mintArrows).toHaveLength(2) + for (const arrow of mintArrows) expectArrowDimensions(arrow, 8, 4) for (const stroke of mintStrokes) { - expect(stroke).toMatchObject({ width: 1.6, dash: [], cap: 'round', alpha: 1 }) + expect(stroke).toMatchObject({ width: 3, dash: [], cap: 'round', alpha: 1 }) expect(stroke.curves).toHaveLength(1) } - expect(completed.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) + expect(completed.fills.filter((fill) => [5, 2.6].includes(fill.arcs[0]?.radius ?? 0))).toEqual([]) }) }) diff --git a/web/src/lib/starmap/focus.ts b/web/src/lib/starmap/focus.ts index 531e9f3..ad91b53 100644 --- a/web/src/lib/starmap/focus.ts +++ b/web/src/lib/starmap/focus.ts @@ -11,7 +11,8 @@ export interface Focus { } function isClosed(ticket: Ticket): boolean { - return ticket.status === 'resolved' || ticket.status === 'out_of_scope' + const status = ticket.focusStatus ?? ticket.status + return status === 'resolved' || status === 'out_of_scope' } interface Route { @@ -56,7 +57,8 @@ function route( const miniOnly = step.miniOnly && miniEdge if (step.throughClosed && !miniEdge) continue if (!goal && isClosed(ticket)) { - if (!allowResolvedMiniRoute || ticket.status !== 'resolved' || !miniOnly) continue + const status = ticket.focusStatus ?? ticket.status + if (!allowResolvedMiniRoute || status !== 'resolved' || !miniOnly) continue } const throughClosed = step.throughClosed || (!goal && isClosed(ticket)) const visit = `${edge.to}|${miniOnly}|${throughClosed}` diff --git a/web/src/lib/starmap/model.ts b/web/src/lib/starmap/model.ts index 5ca2f70..3aa5e9a 100644 --- a/web/src/lib/starmap/model.ts +++ b/web/src/lib/starmap/model.ts @@ -1,5 +1,7 @@ // Derived from chartr (https://github.com/rengwu/chartr), MIT, Copyright (c) 2026 John Goh. +import type { WorkPriority } from './work-priority' + export type TicketStatus = | 'open' | 'blocked' @@ -20,6 +22,11 @@ export interface Ticket { readyForAgent?: boolean assignedToViewer?: boolean blocked?: boolean + visible?: boolean + focusStatus?: TicketStatus + milestone?: string | null + historical?: boolean + workPriority?: WorkPriority } export interface Map { diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index 2d3600e..0b326de 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -13,6 +13,7 @@ import * as layout from './layout' import { computeLayout, structureSignature } from './layout' import { GRAMMAR, nonColorSignature, type SessionState } from './session' import type { Ticket } from './model' +import type { HistoryEvent } from '../history' // A small implementation fixture — a real-shaped graph with a fan-out and a // join, so layout has edges to relax and ranks to bias. @@ -597,6 +598,152 @@ describe('the session overlay on the seam', () => { }) }) +describe('temporal visibility on the renderer seam', () => { + it('enters and leaves neutral historical focus without refitting the camera', () => { + const { sm } = mounted() + const live = fixture().map((ticket) => ({ + ...ticket, + readyForAgent: ticket.num === 2, + focusStatus: ticket.num === 2 ? ('frontier' as const) : ticket.status, + historical: false, + })) + sm.setModel(live, {}, 1) + const camera = sm.camera() + + sm.setModel( + live.map((ticket) => ({ + ...ticket, + status: 'open', + readyForAgent: false, + focusStatus: 'open', + historical: true, + })), + {}, + 1, + ) + expect(sm.camera()).toEqual(camera) + + sm.setModel(live, {}, 1) + expect(sm.camera()).toEqual(camera) + }) + + it('hides future stars without moving the constellation or camera', () => { + const { sm } = mounted() + const current = fixture().map((ticket) => ({ + ...ticket, + visible: true, + focusStatus: ticket.status, + })) + sm.setModel(current, {}, 1) + const positions = sm.positions() + const camera = sm.camera() + + sm.setModel( + current.map((ticket) => ({ + ...ticket, + status: 'open', + visible: ticket.num !== 2, + })), + {}, + 1, + ) + + expect(sm.positions()).toEqual(positions) + expect(sm.camera()).toEqual(camera) + expect(sm.screenOf(1)).not.toBeNull() + expect(sm.screenOf(2)).toBeNull() + }) + + it('reshapes milestone membership without moving stars or the camera', () => { + const { sm } = mounted() + const initial = fixture().map((ticket) => ({ + ...ticket, + milestone: ticket.num <= 2 ? 'Alpha' : null, + })) + sm.setModel(initial) + const positions = sm.positions() + const camera = sm.camera() + + sm.setModel( + initial.map((ticket) => + ticket.num === 2 + ? { ...ticket, milestone: 'Beta' } + : ticket.num === 3 + ? { ...ticket, milestone: 'Alpha' } + : ticket, + ), + ) + + expect(sm.milestoneMemberships()).toEqual({ Alpha: [1, 3], Beta: [2] }) + expect(sm.positions()).toEqual(positions) + expect(sm.camera()).toEqual(camera) + }) + + it('pulses crossed history with bounded captions without moving or selecting', () => { + const { sm } = mounted() + sm.setModel(fixture()) + const positions = sm.positions() + const camera = sm.camera() + const selections: (number | null)[] = [] + sm.onSelect((number) => selections.push(number)) + const crossed: HistoryEvent[] = [ + { + sequence: 1, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'E_created', + occurred_at: 100, + kind: 'issue_created', + milestone: null, + }, + { + sequence: 2, + repository_id: 'R_repo', + issue_id: 'I_2', + issue_number: 2, + provider_event_id: 'E_milestone', + occurred_at: 101, + kind: 'milestone_changed', + from: null, + to: { id: null, title: 'M2' }, + }, + ] + + sm.replayHistory(crossed, false) + + expect(sm.pulsing()).toEqual([1, 2]) + expect(sm.ticker()).toContain('#01 created') + expect(sm.ticker()).toContain('#02 moved to M2') + expect(sm.positions()).toEqual(positions) + expect(sm.camera()).toEqual(camera) + expect(selections).toEqual([]) + }) + + it('keeps playback captions but omits pulses under reduced motion', () => { + const { sm } = mounted() + sm.setModel(fixture()) + + sm.replayHistory( + [ + { + sequence: 1, + repository_id: 'R_repo', + issue_id: 'I_1', + issue_number: 1, + provider_event_id: 'E_reopen', + occurred_at: 100, + kind: 'issue_reopened', + }, + ], + true, + ) + + expect(sm.pulsing()).toEqual([]) + expect(sm.ticker()).toContain('#01 reopened') + }) +}) + // One frame against a recording stub context. The canvas *feel* can only be // judged by eye (starmap-design.md, Open risk), but the draw path for every // session state should at least run, and the one thing the overlay writes as @@ -708,6 +855,22 @@ describe('painting the overlay', () => { expect(texts.some((text) => text.startsWith('CURRENT / READY · 08'))).toBe(true) }) + it('draws milestone titles as canvas text without interpreting markup', () => { + const { sm } = mounted() + const title = 'Launch ' + sm.setModel( + fixture().map((ticket) => ({ + ...ticket, + milestone: ticket.num === 1 ? title : null, + })), + ) + + frame() + + expect(texts).toContain(title) + expect(document.querySelector('script')).toBeNull() + }) + it('draws every session state without falling over', () => { const { sm } = mounted() for (const b of SESSION_LIFECYCLE) { @@ -788,7 +951,14 @@ describe('label placement', () => { // Like stubContext, but recording where each string landed and how it was // aligned, so the drawn boxes can be reconstructed. function recordingContext() { - const drawn: { text: string; x: number; y: number; align: string; font: string }[] = [] + const drawn: { + text: string + x: number + y: number + align: string + font: string + fill: string + }[] = [] const arcs: { x: number y: number @@ -837,6 +1007,7 @@ describe('label placement', () => { y, align: this.textAlign as string, font: this.font as string, + fill: this.fillStyle as string, }) }, } @@ -854,7 +1025,12 @@ describe('label placement', () => { globalThis.requestAnimationFrame = realRaf }) - function place(tickets: Ticket[], sessions: Record = {}) { + function place( + tickets: Ticket[], + sessions: Record = {}, + currentIssue: number | null = null, + selectedIssue: number | null = null, + ) { const { ctx, drawn } = recordingContext() let frames: FrameRequestCallback[] = [] HTMLCanvasElement.prototype.getContext = (() => ctx) as never @@ -865,7 +1041,8 @@ describe('label placement', () => { globalThis.cancelAnimationFrame = (() => {}) as never const { sm } = mounted() - sm.setModel(tickets, sessions) + sm.setModel(tickets, sessions, currentIssue) + if (selectedIssue !== null) sm.select(selectedIssue) const cb = frames.pop() frames = [] cb?.(0) @@ -887,6 +1064,24 @@ describe('label placement', () => { return { sm, labels, boxes } } + it('gives label slots and colours to selected, current, then workflow priority', () => { + const tickets: Ticket[] = [ + { num: 5, slug: '5', title: 'Selected blocked', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: false, workPriority: 'blocked' }, + { num: 6, slug: '6', title: 'Current blocked', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: false, workPriority: 'blocked' }, + { num: 40, slug: '40', title: 'In progress', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: false, workPriority: 'in_progress' }, + { num: 30, slug: '30', title: 'Ready', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: true, readyForAgent: true, workPriority: 'ready' }, + { num: 20, slug: '20', title: 'Frontier', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: true, workPriority: 'frontier' }, + { num: 10, slug: '10', title: 'Blocked', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: false, workPriority: 'blocked' }, + ] + + const { labels } = place(tickets, {}, 6, 5) + const issueNumber = (text: string) => Number(text.match(/\b(\d+)\b/)?.[1]) + + expect(labels.map((label) => issueNumber(label.text))).toEqual([5, 6, 40, 30, 20, 10]) + expect(labels.find((label) => issueNumber(label.text) === 40)?.fill).toBe('#ffe6a0') + expect(labels.find((label) => issueNumber(label.text) === 30)?.fill).toBe('#b3e5ff') + }) + const overlaps = (a: { x0: number; y0: number; x1: number; y1: number }, b: { x0: number; y0: number; x1: number; y1: number }) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index c786bbf..a321cfc 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -19,9 +19,15 @@ import { computeLayout, structureSignature, TAU } from './layout' import type { LayoutPoints } from './layout-loader' -import { STAR, LABEL, SESSION_HUE, hexA, type VisualState } from './theme' +import { + SESSION_HUE, + visualState, + priorityLabelColor, + priorityStarStyle, + hexA, + type VisualState, +} from './theme' import { GRAMMAR, type SessionState } from './session' -import { deriveWorkPriority } from './priority' import { analyzeFocus, type Focus } from './focus' import { edgeKey, isMiniWorkflowEdge, workflowEdges, type WorkflowEdge } from './workflow' import { writeMiniEdgeCurve, type MutableMiniCurve } from './workflow-geometry' @@ -45,6 +51,8 @@ import { } from './label-geometry' import { validOrbitNodeNumbers } from './parent-topology' import type { Ticket } from './model' +import { historyEventSummary, type HistoryEvent } from '../history' +import { ticketWorkPriority, type WorkPriority } from './work-priority' export { clipTitle } from './label-geometry' @@ -57,23 +65,27 @@ export type SelectHandler = (num: number | null) => void const DEFAULT_BG = '#05070d' const CONTEXT_ALPHA = 0.3 const CONTEXT_EDGE_ALPHA = 0.45 +const ISSUE_RADIUS_SCALE = 1.25 const SELECTED_EDGE_WIDTH_SCALE = 1.7 -const SELECTED_EDGE_ARROW_SCALE = 1.25 -const SUBISSUE_RIM = 'rgba(170,145,255,0.82)' const HIT_DISTANCE_EPSILON = 1e-9 interface RenderEdge extends WorkflowEdge { state: WorkflowVisualState satisfied: boolean reverseExists: boolean + historical: boolean } interface Node { num: number title: string type: string + status: Ticket['status'] parentIssue: number | null + visible: boolean + milestone: string | null vstate: VisualState + priority: WorkPriority // The session overlay riding this star, or null when no session speaks for it // (ticket 13). Strictly additive: it never touches layout or the base star. sstate: SessionState | null @@ -101,21 +113,9 @@ interface LabelDraw { type Box = LabelBox // Who gets first pick of the good slots. A contested slot should go to the star -// the operator is most likely to be reading — the selected one, then the bright -// actionable states — rather than to whichever ticket happens to be numbered -// lowest, which is what array order gave us. -const LABEL_PRIORITY: Record = { - attention: 0, - doing_now: 1, - my_next: 2, - my_future: 3, - available_next: 4, - team_work: 5, - planning: 6, - resolved: 7, - out_of_scope: 8, -} - +// the operator is most likely to be reading — selected, distinct CURRENT, then +// the approved workflow priority — rather than to whichever ticket happens to +// be numbered lowest, which is what array order gave us. // A star just off-screen can still own a label that reaches back on-screen, so // the viewport cull keeps a generous skirt around the canvas. const CULL_MARGIN = 140 @@ -238,6 +238,15 @@ function clamp(v: number, a: number, b: number): number { return v < a ? a : v > b ? b : v } +function milestoneHue(title: string): number { + let hash = 2166136261 + for (const codePoint of title) { + hash ^= codePoint.codePointAt(0) ?? 0 + hash = Math.imul(hash, 16777619) + } + return Math.abs(hash) % 360 +} + function focusSignature(focus: Focus): string { return [ focus.current ?? '', @@ -294,6 +303,7 @@ export class StarMap { #tickerAt = -1e9 #focus: Focus = analyzeFocus([], null) + #historical = false // The solved label layout, held between frames. Rebuilt only when its inputs // change (see #drawLabels); `#labelEpoch` is what a model push bumps to say the // text or the colours moved under it. @@ -322,18 +332,6 @@ export class StarMap { this.#ctx = canvas.getContext('2d') this.#dpr = Math.max(1, (typeof window !== 'undefined' && window.devicePixelRatio) || 1) - const motionQuery = typeof window !== 'undefined' && window.matchMedia - ? window.matchMedia('(prefers-reduced-motion: reduce)') - : null - if (motionQuery) { - this.#reducedMotion = motionQuery.matches - const onMotionPreference = (event: MediaQueryListEvent) => { - this.#reducedMotion = event.matches - } - motionQuery.addEventListener?.('change', onMotionPreference) - this.#detach.push(() => motionQuery.removeEventListener?.('change', onMotionPreference)) - } - this.#measure() if (typeof ResizeObserver !== 'undefined') { this.#ro = new ResizeObserver(() => this.#onResize()) @@ -366,6 +364,9 @@ export class StarMap { // Titles and statuses can both move under a push, and both feed the label // solve — retire the cached one either way. this.#labelEpoch++ + const historical = tickets.some((ticket) => ticket.historical === true) + const historicalTransition = historical !== this.#historical + this.#historical = historical const priorFocus = focusSignature(this.#focus) this.#focus = analyzeFocus(tickets, currentIssue) const focusChanged = priorFocus !== focusSignature(this.#focus) @@ -380,18 +381,23 @@ export class StarMap { if (!n) continue n.title = t.title n.type = t.type + n.status = t.status + n.visible = t.visible !== false + n.milestone = t.milestone ?? null + const vstate = visualState(t) + const priority = ticketWorkPriority(t) const sstate = sessions[t.num] ?? null - const vstate = deriveWorkPriority(t, sstate, currentIssue) - if (vstate !== n.vstate || sstate !== n.sstate) { + if (vstate !== n.vstate || priority !== n.priority || sstate !== n.sstate) { n.flare = 1 changed.push(`#${t.num < 10 ? '0' : ''}${t.num} → ${sstate ?? vstate.replace('_', ' ')}`) } n.vstate = vstate + n.priority = priority n.sstate = sstate } if (changed.length) this.#tick(changed.join(' · ')) this.#refreshEdges(tickets) - if (focusChanged) { + if (focusChanged && !historicalTransition) { this.#refit(false) this.#settleIfHeadless() } @@ -418,8 +424,12 @@ export class StarMap { num: t.num, title: t.title, type: t.type, + status: t.status, parentIssue: t.parentIssue, - vstate: deriveWorkPriority(t, sessions[t.num] ?? null, currentIssue), + visible: t.visible !== false, + milestone: t.milestone ?? null, + vstate: visualState(t), + priority: ticketWorkPriority(t), sstate: sessions[t.num] ?? null, x: p.x, y: p.y, @@ -435,15 +445,22 @@ export class StarMap { } #refreshEdges(tickets: Ticket[]): void { + const historical = tickets.some((ticket) => ticket.historical === true) const byNum = new Map(tickets.map((ticket) => [ticket.num, ticket])) const edges = workflowEdges(tickets) const reverseEdges = reverseEdgeKeys(edges) - this.#edges = edges.map((edge) => ({ - ...edge, - state: workflowVisualState(edge, byNum), - satisfied: this.#resolved.has(edge.from), - reverseExists: reverseEdges.has(edgeKey(edge.from, edge.to)), - })) + this.#edges = edges + .filter( + (edge) => + byNum.get(edge.from)?.visible !== false && byNum.get(edge.to)?.visible !== false, + ) + .map((edge) => ({ + ...edge, + state: workflowVisualState(edge, byNum), + satisfied: this.#resolved.has(edge.from), + reverseExists: reverseEdges.has(edgeKey(edge.from, edge.to)), + historical, + })) } // --- seam: emit selection ------------------------------------------------- @@ -451,6 +468,10 @@ export class StarMap { this.#onSelect = cb } + setReducedMotion(reduced: boolean): void { + this.#reducedMotion = reduced + } + // Programmatic selection (a deep-link naming a star, or ticket 07's pane): the // camera eases the star into the space the pane leaves free. select(num: number | null): void { @@ -587,6 +608,43 @@ export class StarMap { return out } + // Apply one playback beat without touching layout, camera, or selection. + // Captions remain under reduced motion; only the radial pulse stands down. + replayHistory(events: HistoryEvent[], reducedMotion: boolean): void { + if (events.length === 0) return + if (!reducedMotion) { + for (const event of events) { + const node = this.#byNum.get(event.issue_number) + if (node) node.flare = 1 + } + } + const summaries = events.map((event) => historyEventSummary(event, 2)) + const caption = + summaries.length <= 3 + ? summaries.join(' · ') + : `${summaries.length} events · ${summaries.slice(0, 2).join(' · ')}` + this.#tick(caption.slice(0, 180)) + } + + pulsing(): number[] { + return this.#nodes + .filter((node) => node.flare > 0) + .map((node) => node.num) + .sort((a, b) => a - b) + } + + // Temporal milestone membership, grouped exactly as the hull renderer sees + // it. Membership is visual state only: it never participates in layout. + milestoneMemberships(): Record { + const out: Record = {} + for (const n of this.#nodes) { + if (!n.visible || n.milestone === null) continue + ;(out[n.milestone] ??= []).push(n.num) + } + for (const members of Object.values(out)) members.sort((a, b) => a - b) + return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b))) + } + // The live ticker line, or null once it has faded — one line per pushed change. ticker(): string | null { return this.#tickerAlpha() > 0 ? this.#tickerText : null @@ -595,13 +653,12 @@ export class StarMap { // Current screen position of a star under the live camera. screenOf(num: number): { x: number; y: number } | null { const n = this.#byNum.get(num) - if (!n) return null + if (!n || !n.visible) return null return { x: n.x * this.#cam.s + this.#cam.x, y: n.y * this.#cam.s + this.#cam.y } } #radius(n: Node): number { - const style = STAR[n.vstate] - return Math.max(style.r, style.minScreen / this.#cam.s) + return priorityStarStyle(n.vstate, n.priority).r * ISSUE_RADIUS_SCALE } // Hit-test a screen point and, if it lands on a star, select and emit it — the @@ -614,9 +671,11 @@ export class StarMap { let subissueHit: Node | null = null let subissueDistance = Number.POSITIVE_INFINITY for (const n of this.#nodes) { + if (!n.visible) continue const px = n._x * this.#cam.s + this.#cam.x const py = n._y * this.#cam.s + this.#cam.y - const baseRadius = Math.max(14, this.#radius(n) * this.#cam.s + 10) + const minimumHitRadius = this.#focus.current === n.num ? 20 : 14 + const baseRadius = Math.max(minimumHitRadius, this.#radius(n) * this.#cam.s + 10) const hasValidParent = this.#validOrbitNodes.has(n.num) const radius = hasValidParent ? Math.max(28, baseRadius + 8) : baseRadius const distance = Math.hypot(sx - px, sy - py) @@ -990,6 +1049,7 @@ export class StarMap { g.translate(this.#cam.x, this.#cam.y) g.scale(this.#cam.s, this.#cam.s) const focused = this.#focus.emphasized.size > 0 + this.#drawMilestoneHulls(g) for (const edge of this.#edges) { if (this.#isSelectedEdge(edge)) continue g.save() @@ -1007,6 +1067,7 @@ export class StarMap { g.restore() } for (const n of this.#nodes) { + if (!n.visible) continue g.save() if (focused && !this.#focus.emphasized.has(n.num)) g.globalAlpha = CONTEXT_ALPHA this.#drawStar(g, n, this.#motionClock) @@ -1024,6 +1085,48 @@ export class StarMap { ) } + #drawMilestoneHulls(g: CanvasRenderingContext2D): void { + const groups = new Map() + for (const n of this.#nodes) { + if (!n.visible || n.milestone === null) continue + const members = groups.get(n.milestone) ?? [] + members.push(n) + groups.set(n.milestone, members) + } + + for (const [title, members] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { + const minX = Math.min(...members.map((member) => member.x)) - 34 + const maxX = Math.max(...members.map((member) => member.x)) + 34 + const minY = Math.min(...members.map((member) => member.y)) - 30 + const maxY = Math.max(...members.map((member) => member.y)) + 34 + const radius = Math.min(18, (maxX - minX) / 2, (maxY - minY) / 2) + const hue = milestoneHue(title) + + g.beginPath() + g.moveTo(minX + radius, minY) + g.lineTo(maxX - radius, minY) + g.quadraticCurveTo(maxX, minY, maxX, minY + radius) + g.lineTo(maxX, maxY - radius) + g.quadraticCurveTo(maxX, maxY, maxX - radius, maxY) + g.lineTo(minX + radius, maxY) + g.quadraticCurveTo(minX, maxY, minX, maxY - radius) + g.lineTo(minX, minY + radius) + g.quadraticCurveTo(minX, minY, minX + radius, minY) + g.closePath() + g.fillStyle = `hsla(${hue}, 70%, 62%, 0.08)` + g.fill() + g.strokeStyle = `hsla(${hue}, 72%, 72%, 0.42)` + g.lineWidth = 1.5 + g.setLineDash([5, 5]) + g.stroke() + g.setLineDash([]) + g.font = '10px ui-sans-serif,system-ui,sans-serif' + g.textAlign = 'left' + g.fillStyle = `hsla(${hue}, 78%, 82%, 0.9)` + g.fillText(title, minX + 10, minY + 15) + } + } + #drawEdge(g: CanvasRenderingContext2D, e: RenderEdge, selected = false): void { const a = this.#byNum.get(e.from), b = this.#byNum.get(e.to) @@ -1071,11 +1174,15 @@ export class StarMap { g.moveTo(ax, ay) g.quadraticCurveTo(cx, cy, bx, by) g.lineCap = 'round' - const usesResolvedStyle = e.state !== 'incomplete' || e.satisfied + const usesResolvedStyle = mini ? e.state !== 'incomplete' : e.satisfied const strokeScale = selected ? SELECTED_EDGE_WIDTH_SCALE : 1 - if (usesResolvedStyle) { - g.strokeStyle = 'rgba(150,178,160,0.36)' - g.lineWidth = 1.6 * strokeScale + if (e.historical) { + g.strokeStyle = 'rgba(174,192,218,0.32)' + g.lineWidth = 1.5 * strokeScale + g.setLineDash([4, 8]) + } else if (usesResolvedStyle) { + g.strokeStyle = 'rgba(190,225,200,0.82)' + g.lineWidth = 3 * strokeScale g.setLineDash([]) } else if (mini) { g.strokeStyle = 'rgba(170,145,255,0.78)' @@ -1088,20 +1195,27 @@ export class StarMap { } g.stroke() g.setLineDash([]) - // Historical paths stay quiet. Only a traversed edge directed into work the - // operator can act on now carries two small particles toward that endpoint. + // Historical paths stay quiet. In the live map, a satisfied edge flows + // blocker→dependent so the frontier visibly ignites as paths clear. const animatesIntoActiveWork = - e.state === 'traversed' && - (b.vstate === 'doing_now' || b.vstate === 'my_next' || b.vstate === 'available_next') + !e.historical && + (mini ? e.state === 'traversed' : e.satisfied) && + child?.status !== 'resolved' && + child?.status !== 'out_of_scope' && + b.status !== 'blocked' if (animatesIntoActiveWork) { - for (let k = 0; k < 2; k++) { - const u = mod(this.#motionClock * 0.1 + k / 2 + (e.from * 0.13 + e.to * 0.07), 1), + for (let k = 0; k < 3; k++) { + const u = mod(this.#motionClock * 0.1 + k / 3 + (e.from * 0.13 + e.to * 0.07), 1), m = 1 - u const fx = m * m * ax + 2 * m * u * cx + u * u * bx, fy = m * m * ay + 2 * m * u * cy + u * u * by - g.fillStyle = 'rgba(190,218,198,' + (0.35 + 0.4 * Math.sin(u * Math.PI)) + ')' + g.fillStyle = 'rgba(190,225,200,' + (0.14 + 0.18 * Math.sin(u * Math.PI)) + ')' g.beginPath() - g.arc(fx, fy, 1.8, 0, TAU) + g.arc(fx, fy, 5, 0, TAU) + g.fill() + g.fillStyle = 'rgba(220,255,230,' + (0.45 + 0.5 * Math.sin(u * Math.PI)) + ')' + g.beginPath() + g.arc(fx, fy, 2.6, 0, TAU) g.fill() } } @@ -1113,9 +1227,8 @@ export class StarMap { al = Math.hypot(tangentX, tangentY) || 1, ux = tangentX / al, uy = tangentY / al - const arrowScale = selected ? SELECTED_EDGE_ARROW_SCALE : 1 - const ah = 12 * arrowScale, - aw = 6.5 * arrowScale, + const ah = 8, + aw = 4, px = -uy, py = ux, tipx = midx + ux * ah * 0.5, @@ -1125,24 +1238,25 @@ export class StarMap { g.lineTo(tipx - ux * ah + px * aw, tipy - uy * ah + py * aw) g.lineTo(tipx - ux * ah - px * aw, tipy - uy * ah - py * aw) g.closePath() - g.fillStyle = usesResolvedStyle - ? 'rgba(190,218,198,0.52)' - : mini - ? '#c7b8ff' - : '#c8d5e8' + g.fillStyle = e.historical + ? 'rgba(174,192,218,0.5)' + : usesResolvedStyle + ? '#d9f3df' + : mini + ? '#c7b8ff' + : '#c8d5e8' g.fill() } #drawStar(g: CanvasRenderingContext2D, n: Node, t: number): void { - const c = STAR[n.vstate] + const c = priorityStarStyle(n.vstate, n.priority) const x = n._x, y = n._y, - fl = n.flare || 0 - const cr = this.#radius(n) - const beat = 0.5 + 0.5 * Math.sin(t * 2.8) - const corePulse = n.vstate === 'doing_now' && !this.#reducedMotion ? 1 + 0.08 * beat : 1 - const pulse = 1 - const gr = c.gr * (cr / c.r) * (1 + fl * 0.5) + fl = this.#reducedMotion ? (n.flare > 0 ? 1 : 0) : n.flare || 0 + const isF = n.priority === 'ready' || n.priority === 'frontier' + const beat = this.#reducedMotion ? 0.5 : 0.5 + 0.5 * Math.sin(t * 2.8) + const pulse = isF ? 0.8 + 0.2 * beat : 1 + const gr = (isF ? c.gr * (0.92 + 0.16 * beat) : c.gr) * (1 + fl * 0.5) const grd = g.createRadialGradient(x, y, 0, x, y, gr) grd.addColorStop(0, hexA(c.glow, Math.min(1, 0.85 * pulse + fl * 0.5))) @@ -1153,76 +1267,79 @@ export class StarMap { g.arc(x, y, gr, 0, TAU) g.fill() - const paintedCoreRadius = cr * corePulse - const hasSubissueRim = n.parentIssue !== null && n.vstate !== 'resolved' && n.vstate !== 'out_of_scope' - if (hasSubissueRim) { - g.strokeStyle = SUBISSUE_RIM - g.lineWidth = 1.5 - g.beginPath() - g.arc(x, y, cr + 4, 0, TAU) - g.stroke() - } - if (c.solid && n.vstate !== 'resolved') { - g.fillStyle = c.core - g.beginPath() - g.arc(x, y, paintedCoreRadius, 0, TAU) - g.fill() - } else if (n.vstate === 'resolved') { - const cg = g.createRadialGradient(x, y, 0, x, y, cr * 1.35) - cg.addColorStop(0, hexA(c.core, 1)) - cg.addColorStop(0.6, hexA(c.core, 0.92)) - cg.addColorStop(0.82, hexA(c.core, 0.45)) - cg.addColorStop(1, hexA(c.core, 0)) - g.fillStyle = cg - g.beginPath() - g.arc(x, y, cr * 1.35, 0, TAU) - g.fill() - } else { - g.fillStyle = '#000' - g.beginPath() - g.arc(x, y, cr, 0, TAU) - g.fill() - const lineWidth = Math.max(2.2, cr * 0.32) - g.strokeStyle = hexA(c.core, 0.95) - g.lineWidth = lineWidth - g.beginPath() - g.arc(x, y, cr - lineWidth / 2, 0, TAU) - g.stroke() - } + const cr = this.#radius(n) + const shadowY = y + cr * 0.72 + const shadowRadius = cr * 1.08 + const shadow = g.createRadialGradient(x, shadowY, 0, x, shadowY, shadowRadius) + shadow.addColorStop(0, 'rgba(0,0,0,0.26)') + shadow.addColorStop(1, 'rgba(0,0,0,0)') + g.fillStyle = shadow + g.beginPath() + g.arc(x, shadowY, shadowRadius, 0, TAU) + g.fill() - if (hasSubissueRim && this.#focus.readySet.has(n.num) && this.#focus.current !== n.num) { - g.strokeStyle = hexA(c.core, 0.95) - g.lineWidth = 2 - g.beginPath() - g.arc(x, y, cr + 8, 0, TAU) - g.stroke() - } + const body = g.createRadialGradient( + x - cr * 0.32, + y - cr * 0.34, + 0, + x, + y, + cr * 1.12, + ) + body.addColorStop(0, hexA(c.core, 1)) + body.addColorStop(0.48, hexA(c.core, 0.98)) + body.addColorStop(0.82, hexA(c.glow, 0.92)) + body.addColorStop(1, hexA(c.glow, 0.62)) + g.fillStyle = body + g.beginPath() + g.arc(x, y, cr, 0, TAU) + g.fill() - if (fl > 0) { - g.strokeStyle = hexA(c.core, fl * 0.7) - g.lineWidth = 1.5 + 2 * fl - g.beginPath() - g.arc(x, y, cr + (1 - fl) * 40, 0, TAU) - g.stroke() - } - if (n.sstate) this.#drawSession(g, n, x, y, cr, t) - if (this.#focus.current === n.num) { - g.strokeStyle = 'rgba(255,255,255,0.95)' - g.lineWidth = 2 + const specularX = x - cr * 0.32 + const specularY = y - cr * 0.34 + const specularRadius = cr * 0.28 + const specular = g.createRadialGradient( + specularX, + specularY, + 0, + specularX, + specularY, + specularRadius, + ) + specular.addColorStop(0, 'rgba(255,255,255,0.46)') + specular.addColorStop(1, 'rgba(255,255,255,0)') + g.fillStyle = specular + g.beginPath() + g.arc(specularX, specularY, specularRadius, 0, TAU) + g.fill() + + const boundaryWidth = Math.max(1, cr * 0.14) + g.strokeStyle = hexA(c.core, 0.72) + g.lineWidth = boundaryWidth + g.beginPath() + g.arc(x, y, cr - boundaryWidth / 2, 0, TAU) + g.stroke() + + const hasStatusRing = n.priority === 'in_progress' || n.priority === 'ready' + if (n.priority === 'in_progress') { + g.strokeStyle = hexA(c.core, 0.45 + 0.25 * beat) + g.lineWidth = 1.5 g.beginPath() - g.arc(x, y, cr + 8, 0, TAU) + g.arc(x, y, cr + 7 + beat, 0, TAU) g.stroke() - g.strokeStyle = 'rgba(255,255,255,0.55)' - g.lineWidth = 1 + } else if (n.priority === 'ready') { + g.strokeStyle = hexA(c.core, 0.82) + g.lineWidth = 1.5 g.beginPath() - g.arc(x, y, cr + 13, 0, TAU) + g.arc(x, y, cr + 9, 0, TAU) g.stroke() } + if (n.sstate) this.#drawSession(g, n, x, y, cr, t) if (this.#selected === n.num) { g.strokeStyle = 'rgba(255,255,255,0.85)' g.lineWidth = 1.5 g.beginPath() - g.arc(x, y, cr + (this.#focus.current === n.num ? 18 : 13), 0, TAU) + g.arc(x, y, cr + (hasStatusRing ? 13 : 7), 0, TAU) g.stroke() } } @@ -1401,6 +1518,7 @@ export class StarMap { // text over the faint outer falloff reads fine. const vis: { n: Node; sx: number; sy: number; rad: number }[] = [] for (const n of this.#nodes) { + if (!n.visible) continue const sx = n.x * s + this.#cam.x const sy = n.y * s + this.#cam.y if (sx < -CULL_MARGIN || sx > this.#w + CULL_MARGIN) continue @@ -1408,13 +1526,9 @@ export class StarMap { const core = this.#radius(n) let r = core + 2 if (n.sstate) r = core + 15 - if ( - n.parentIssue !== null && - this.#focus.readySet.has(n.num) && - this.#focus.current !== n.num - ) r = Math.max(r, core + 9) - if (this.#focus.current === n.num) r = Math.max(r, core + 14) - if (this.#selected === n.num) r = Math.max(r, core + 19) + const hasStatusRing = n.priority === 'in_progress' || n.priority === 'ready' + if (hasStatusRing) r = Math.max(r, core + 10) + if (this.#selected === n.num) r = Math.max(r, core + (hasStatusRing ? 15 : 9)) vis.push({ n, sx, sy, rad: r * s }) } @@ -1428,11 +1542,15 @@ export class StarMap { const order = [...vis].sort((a, b) => { const priority = (n: Node) => { - if (this.#focus.current === n.num) return 0 - if (this.#focus.readySet.has(n.num)) return 1 - if (this.#selected === n.num) return 2 - if (this.#focus.pathNodes.has(n.num)) return 3 - return 4 + LABEL_PRIORITY[n.vstate] + if (this.#selected === n.num) return 0 + if (this.#focus.current === n.num) return 1 + switch (n.priority) { + case 'in_progress': return 2 + case 'ready': return 3 + case 'frontier': return 4 + case 'blocked': return 5 + case 'terminal': return n.vstate === 'resolved' ? 6 : 7 + } } const pa = priority(a.n) const pb = priority(b.n) @@ -1489,7 +1607,7 @@ export class StarMap { y: geometry.y, align: geometry.align, fontSize: labelFontSize, - fill: LABEL[v.n.vstate], + fill: priorityLabelColor(v.n.vstate, v.n.priority), alpha: this.#focus.emphasized.size === 0 || this.#focus.emphasized.has(v.n.num) ? 1 @@ -1538,7 +1656,7 @@ export class StarMap { y: c.y, align: 'center', fontSize: labelFontSize, - fill: LABEL[v.n.vstate], + fill: priorityLabelColor(v.n.vstate, v.n.priority), alpha: this.#focus.emphasized.size === 0 || this.#focus.emphasized.has(v.n.num) ? 1 diff --git a/web/src/lib/starmap/theme.test.ts b/web/src/lib/starmap/theme.test.ts new file mode 100644 index 0000000..921185b --- /dev/null +++ b/web/src/lib/starmap/theme.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { priorityLabelColor, priorityStarStyle } from './theme' + +describe('priority visual theme', () => { + it('orders active work without changing its semantic palette', () => { + expect(priorityStarStyle('blocked', 'in_progress')).toMatchObject({ + core: '#ffd873', + r: 8.1, + gr: 42, + }) + expect(priorityStarStyle('frontier', 'ready')).toMatchObject({ + core: '#8ad8ff', + r: 7.2, + gr: 34, + }) + expect(priorityStarStyle('frontier', 'frontier')).toMatchObject({ + core: '#8ad8ff', + r: 6.2, + gr: 28, + }) + expect(priorityStarStyle('blocked', 'blocked')).toMatchObject({ + core: '#e2c3c3', + r: 4.5, + gr: 20, + }) + expect(priorityStarStyle('resolved', 'terminal')).toMatchObject({ + core: '#b9d6c4', + r: 5.4, + gr: 24, + }) + expect(priorityStarStyle('out_of_scope', 'terminal')).toMatchObject({ + core: '#948da4', + r: 4.5, + gr: 18, + }) + expect(priorityLabelColor('blocked', 'in_progress')).toBe('#ffe6a0') + expect(priorityLabelColor('frontier', 'ready')).toBe('#b3e5ff') + }) +}) diff --git a/web/src/lib/starmap/theme.ts b/web/src/lib/starmap/theme.ts index 753e3ec..bd0d2c5 100644 --- a/web/src/lib/starmap/theme.ts +++ b/web/src/lib/starmap/theme.ts @@ -1,47 +1,104 @@ -// The star-map palette expresses work priority, while session liveness remains -// an additive moon/mark overlay. Keeping the palette keyed by the derived -// priority prevents transport status from leaking into rendering decisions. +// The star-map's visual vocabulary for the five base states, cribbed from the +// shipped wayfinder-maps viewer's palette so the feel carries over (ADR 0010: +// constants cribbed to prevent feel-drift). Status is the whole star — colour, +// size, glow, pulse (starmap-design.md, decision 4). Ticket type rides only in +// the label, never the celestial body. +// +// chartr derives its ticket status from `.plan/` (ADR 0004, amended): a +// ticket is open, claimed, resolved, or out_of_scope, with a `frontier` flag +// splitting open into the takeable edge and the still-blocked interior. That is +// exactly five visual states, and this module maps the derived status onto them. +// Session liveness (working / blocked / dead) is a strictly-additive overlay a +// later ticket layers on top — this palette is only the base star. -import type { WorkPriority } from './priority' +// Derived from chartr (https://github.com/rengwu/chartr), MIT, Copyright (c) 2026 John Goh. -export type VisualState = WorkPriority +import type { Ticket } from './model' +import type { WorkPriority } from './work-priority' + +export type VisualState = + | 'resolved' + | 'frontier' + | 'claimed' + | 'blocked' + | 'out_of_scope' export interface StarStyle { core: string glow: string r: number - minScreen: number gr: number - solid: boolean } +// The five base states, ported verbatim from the viewer's theme — chartr +// adds none of its own (the `proposed` star went with the review feature). +// Ticket 04: the card the map sits on moved from a near-black `#05070d` to the +// theme's warm near-black `--card` (`oklch(0.228 0.013 107.4)`, ~`#1d1d16`) — +// meaningfully lighter than before. Four of five states still clear WCAG-ish +// contrast comfortably against it; `out_of_scope`, deliberately the dimmest +// star, fell under 4:1. Its three values are lifted just enough to stay +// legible on the new card; sizes, glow radii, and every other state are +// untouched (map decision: this is a palette re-tune, not a renderer change). export const STAR: Record = { - attention: { core: '#ffd873', glow: '#ffb020', r: 12, minScreen: 10, gr: 52, solid: true }, - doing_now: { core: '#ffd873', glow: '#ffb020', r: 12, minScreen: 10, gr: 49, solid: true }, - my_next: { core: '#8ad8ff', glow: '#2f9be0', r: 11, minScreen: 8.5, gr: 49, solid: true }, - my_future: { core: '#8ad8ff', glow: '#2f9be0', r: 10, minScreen: 7, gr: 42, solid: false }, - available_next: { core: '#8ed7ac', glow: '#3b9f68', r: 10, minScreen: 7, gr: 42, solid: false }, - team_work: { core: '#b9a7ee', glow: '#775dc1', r: 9, minScreen: 6, gr: 36, solid: true }, - planning: { core: '#aaa0bd', glow: '#716884', r: 5.625, minScreen: 0, gr: 20, solid: false }, - resolved: { core: '#b9d6c4', glow: '#5b9077', r: 6.75, minScreen: 0, gr: 24, solid: true }, - out_of_scope: { core: '#948da4', glow: '#6b6478', r: 5.625, minScreen: 0, gr: 18, solid: false }, + resolved: { core: '#b9d6c4', glow: '#5b9077', r: 5.4, gr: 24 }, + frontier: { core: '#8ad8ff', glow: '#2f9be0', r: 8.1, gr: 49 }, + claimed: { core: '#ffd873', glow: '#ffb020', r: 7.2, gr: 36 }, + blocked: { core: '#e2c3c3', glow: '#9a6f6f', r: 4.5, gr: 20 }, + out_of_scope: { core: '#948da4', glow: '#6b6478', r: 4.5, gr: 18 }, } export const LABEL: Record = { - attention: '#ffe6a0', - doing_now: '#ffe6a0', - my_next: '#b3e5ff', - my_future: '#b3e5ff', - available_next: '#b9eccd', - team_work: '#d6ccf6', - planning: '#c8bfd7', resolved: '#a2c1ac', + frontier: '#b3e5ff', + claimed: '#ffe6a0', + blocked: '#d0b3b3', out_of_scope: '#a89fb2', } +export function priorityStarStyle(vstate: VisualState, priority: WorkPriority): StarStyle { + if (priority === 'in_progress') return { ...STAR.claimed, r: 8.1, gr: 42 } + if (priority === 'ready') return { ...STAR.frontier, r: 7.2, gr: 34 } + if (priority === 'frontier') return { ...STAR.frontier, r: 6.2, gr: 28 } + return STAR[vstate] +} + +export function priorityLabelColor(vstate: VisualState, priority: WorkPriority): string { + if (priority === 'in_progress') return LABEL.claimed + if (priority === 'ready' || priority === 'frontier') return LABEL.frontier + return LABEL[vstate] +} + +// Derive the visual state of a ticket from its pushed status and frontier flag. +// The frontier flag is what splits an open ticket into the bright, takeable +// `frontier` star and the small, dim `blocked` one — the whole reason the map +// exists is this at-a-glance read. +export function visualState(t: Pick): VisualState { + switch (t.status) { + case 'resolved': + return 'resolved' + case 'claimed': + return 'claimed' + case 'out_of_scope': + return 'out_of_scope' + case 'open': + default: + return t.frontier ? 'frontier' : 'blocked' + } +} + +// The session overlay's hues (ticket 13). They live here with the five base +// states because the star-map's palette is the island's own exempt data-viz +// colour (docs/design-system.md) — the chrome around it stays monochrome. The +// grammar these serve, and the non-colour channel each state also carries, is +// session.ts; the amber is the spec's session moon (story 25), so the set is +// closed here, not open to growth. export const SESSION_HUE = { + // The session itself: the same amber as a live claim, because the moon *is* + // the claim's body. session: '#ffd873', + // The island's own chrome: the ticker line naming what just changed. gold: '#ffe6a0', + // A dead session greys its whole orbital apparatus, not just the moon. dead: '#6b7280', } as const diff --git a/web/src/lib/starmap/work-priority.test.ts b/web/src/lib/starmap/work-priority.test.ts new file mode 100644 index 0000000..0d8c976 --- /dev/null +++ b/web/src/lib/starmap/work-priority.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { deriveWorkPriority } from './work-priority' + +describe('deriveWorkPriority', () => { + it.each([ + ['resolved overrides stale labels', 'resolved', ['in-progress'], ['ada'], 'terminal'], + ['out of scope overrides stale labels', 'out_of_scope', ['ready-for-agent'], [], 'terminal'], + ['literal in progress outranks a blocker', 'blocked', ['IN-PROGRESS'], [], 'in_progress'], + ['claimed is the assigned fallback', 'claimed', [], ['ada'], 'in_progress'], + ['an assignee is the assigned fallback', 'frontier', [], ['ada'], 'in_progress'], + ['ready requires an unblocked frontier', 'frontier', ['READY-FOR-AGENT'], [], 'ready'], + ['ready cannot override blocked', 'blocked', ['ready-for-agent'], [], 'blocked'], + ['unlabelled unblocked work stays frontier', 'frontier', [], [], 'frontier'], + ] as const)('%s', (_name, status, labels, assignees, expected) => { + expect(deriveWorkPriority({ status, labels, assignees })).toBe(expected) + }) +}) diff --git a/web/src/lib/starmap/work-priority.ts b/web/src/lib/starmap/work-priority.ts new file mode 100644 index 0000000..5df4bb1 --- /dev/null +++ b/web/src/lib/starmap/work-priority.ts @@ -0,0 +1,32 @@ +import type { Status } from '../model' +import type { Ticket } from './model' + +export type WorkPriority = 'in_progress' | 'ready' | 'frontier' | 'blocked' | 'terminal' + +export interface WorkPriorityInput { + status: Status + labels: readonly string[] + assignees: readonly string[] +} + +function hasLabel(labels: readonly string[], expected: string): boolean { + return labels.some((label) => label.toLowerCase() === expected) +} + +export function deriveWorkPriority(input: WorkPriorityInput): WorkPriority { + if (input.status === 'resolved' || input.status === 'out_of_scope') return 'terminal' + if (hasLabel(input.labels, 'in-progress')) return 'in_progress' + if (input.status === 'claimed' || input.assignees.length > 0) return 'in_progress' + if (input.status === 'frontier' && hasLabel(input.labels, 'ready-for-agent')) return 'ready' + return input.status === 'blocked' ? 'blocked' : 'frontier' +} + +export function ticketWorkPriority( + ticket: Pick, +): WorkPriority { + if (ticket.workPriority) return ticket.workPriority + if (ticket.status === 'resolved' || ticket.status === 'out_of_scope') return 'terminal' + if (ticket.status === 'claimed') return 'in_progress' + if (ticket.readyForAgent) return 'ready' + return ticket.frontier ? 'frontier' : 'blocked' +}