From 08bbaa835618aa2c09ede1762eb464cff41c06a4 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:59:38 -0500 Subject: [PATCH 01/18] docs: design temporal issue history slider --- ...26-08-04-temporal-history-slider-design.md | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-temporal-history-slider-design.md 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. From ed21975679b083d47766e5f960e13fe2ecccf554 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:42:50 -0500 Subject: [PATCH 02/18] docs: plan temporal history implementation --- .../2026-08-04-temporal-history-slider.md | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-temporal-history-slider.md 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. From 623704e8f36b760bbd4595fc8c325a0a93fe2654 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:26:32 -0500 Subject: [PATCH 03/18] feat(history): scrub issue creation from local ledger Closes #78 --- CHANGELOG.md | 3 + Cargo.lock | 92 +++++ Cargo.toml | 2 + crates/app/Cargo.toml | 1 + crates/app/src/runtime.rs | 24 +- crates/app/tests/application_process_test.rs | 4 + crates/core/Cargo.toml | 1 + crates/core/src/history.rs | 102 ++++++ crates/core/src/lib.rs | 7 +- crates/core/src/model.rs | 5 + crates/core/src/provider.rs | 29 +- crates/core/tests/model_contract_test.rs | 138 +++++++ crates/github/src/sync.rs | 68 +++- crates/github/tests/sync_test.rs | 71 +++- crates/history/Cargo.toml | 15 + crates/history/src/lib.rs | 3 + crates/history/src/store.rs | 357 +++++++++++++++++++ crates/history/tests/store_test.rs | 89 +++++ crates/server/Cargo.toml | 1 + crates/server/src/poll.rs | 74 +++- crates/server/src/routes.rs | 54 ++- crates/server/src/state.rs | 2 + crates/server/tests/api_test.rs | 179 +++++++++- crates/server/tests/focus_polling_test.rs | 2 + web/src/App.svelte | 49 ++- web/src/App.test.ts | 84 ++++- web/src/lib/StarMap.svelte | 9 +- web/src/lib/TemporalTimeline.svelte | 131 +++++++ web/src/lib/TemporalTimeline.test.ts | 87 +++++ web/src/lib/history-api.ts | 16 + web/src/lib/history.test.ts | 103 ++++++ web/src/lib/history.ts | 73 ++++ web/src/lib/model.ts | 20 ++ web/src/lib/starmap/adapt.ts | 14 +- web/src/lib/starmap/focus.ts | 6 +- web/src/lib/starmap/model.ts | 2 + web/src/lib/starmap/starmap.test.ts | 29 ++ web/src/lib/starmap/starmap.ts | 17 +- 38 files changed, 1925 insertions(+), 38 deletions(-) create mode 100644 crates/core/src/history.rs create mode 100644 crates/core/tests/model_contract_test.rs create mode 100644 crates/history/Cargo.toml create mode 100644 crates/history/src/lib.rs create mode 100644 crates/history/src/store.rs create mode 100644 crates/history/tests/store_test.rs create mode 100644 web/src/lib/TemporalTimeline.svelte create mode 100644 web/src/lib/TemporalTimeline.test.ts create mode 100644 web/src/lib/history-api.ts create mode 100644 web/src/lib/history.test.ts create mode 100644 web/src/lib/history.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b23b52b..8d36860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- 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/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/crates/app/Cargo.toml b/crates/app/Cargo.toml index 3ee6e2c..59bc408 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -18,6 +18,7 @@ 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 diff --git a/crates/app/src/runtime.rs b/crates/app/src/runtime.rs index baeb176..cf11ea8 100644 --- a/crates/app/src/runtime.rs +++ b/crates/app/src/runtime.rs @@ -1,7 +1,11 @@ use std::{io, net::SocketAddr, num::NonZeroU64, path::PathBuf, sync::Arc, time::Duration}; -use stellr_core::{Model, Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{ + HistoryPage, HistoryPageRequest, Model, Provider, ProviderError, ProviderSnapshot, RawIssue, + RepoRef, +}; use stellr_github::cache::Cache; +use stellr_history::HistoryStore; use stellr_server::{ poll::{PollingControl, spawn_controlled_poller}, routes::router, @@ -37,6 +41,20 @@ impl Provider for ProviderSlot { let provider = self.current.read().await.clone(); provider.fetch(repo).await } + + async fn fetch_snapshot(&self, repo: &RepoRef) -> Result { + let provider = self.current.read().await.clone(); + provider.fetch_snapshot(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)] @@ -143,6 +161,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( @@ -167,12 +187,14 @@ pub async fn start_with_polling( 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 35691cf..f2ed9bc 100644 --- a/crates/app/tests/application_process_test.rs +++ b/crates/app/tests/application_process_test.rs @@ -34,10 +34,14 @@ async fn controlled_github() -> (String, tokio::task::JoinHandle<()>) { Json(json!({ "data": { "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/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..0efcfe8 --- /dev/null +++ b/crates/core/src/history.rs @@ -0,0 +1,102 @@ +use std::cmp::Ordering; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MilestoneRef { + pub id: String, + 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 complete: bool, +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 861e2f1..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, RepoRef}; +pub use provider::{Provider, ProviderError, ProviderSnapshot, RepoRef}; diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 27b7a27..2c452eb 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 { @@ -57,6 +59,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)] @@ -98,6 +102,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 77948b3..7bab911 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -1,4 +1,4 @@ -use crate::RawIssue; +use crate::{HistoryPage, HistoryPageRequest, IssueSyncMetadata, RawIssue}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRef { @@ -15,6 +15,31 @@ impl RepoRef { #[async_trait::async_trait] pub trait Provider { async fn fetch(&self, repo: &RepoRef) -> Result, ProviderError>; + + async fn fetch_snapshot(&self, repo: &RepoRef) -> Result { + Ok(ProviderSnapshot { + repository_id: None, + issues: self.fetch(repo).await?, + history: Vec::new(), + }) + } + + async fn fetch_history_page( + &self, + _repo: &RepoRef, + _request: &HistoryPageRequest, + ) -> Result { + Err(ProviderError::Unsupported( + "provider does not supply temporal history".into(), + )) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderSnapshot { + pub repository_id: Option, + pub issues: Vec, + pub history: Vec, } #[derive(Debug, thiserror::Error)] @@ -27,4 +52,6 @@ pub enum ProviderError { Http(String), #[error("response parsing failed: {0}")] Parse(String), + #[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..3d48560 --- /dev/null +++ b/crates/core/tests/model_contract_test.rs @@ -0,0 +1,138 @@ +use stellr_core::{ + HistoryEvent, HistoryEventKind, HistoryImportState, HistorySummary, IssueSyncMetadata, + MilestoneRef, Model, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef, SpaceModel, +}; + +struct CurrentOnlyProvider; + +#[async_trait::async_trait] +impl Provider for CurrentOnlyProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + Ok(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 { + repository_id: None, + issues: Vec::new(), + history: 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: "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(), + stars: Vec::new(), + synced_at: None, + stale: false, + error: None, + history: HistorySummary::default(), + }; +} diff --git a/crates/github/src/sync.rs b/crates/github/src/sync.rs index 0b98ece..b6e593e 100644 --- a/crates/github/src/sync.rs +++ b/crates/github/src/sync.rs @@ -1,9 +1,13 @@ 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, RawIssue, RepoRef}; +use stellr_core::{ + IssueState, IssueSyncMetadata, MilestoneRef, Provider, ProviderError, ProviderSnapshot, + RawIssue, RepoRef, +}; use crate::textref; @@ -12,13 +16,17 @@ const DEFAULT_BASE_URI: &str = "https://api.github.com"; const FETCH_ISSUES_QUERY: &str = r#" query FetchIssues($owner: String!, $name: String!, $cursor: String) { 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 @@ -27,7 +35,7 @@ query FetchIssues($owner: String!, $name: String!, $cursor: String) { assignees(first: 10) { nodes { login } } - milestone { title } + milestone { id title } labels(first: 20) { nodes { name } } @@ -138,8 +146,19 @@ impl GithubProvider { #[async_trait::async_trait] impl Provider for GithubProvider { async fn fetch(&self, repo: &RepoRef) -> Result, ProviderError> { + Ok(self.fetch_all(repo).await?.issues) + } + + async fn fetch_snapshot(&self, repo: &RepoRef) -> Result { + self.fetch_all(repo).await + } +} + +impl GithubProvider { + async fn fetch_all(&self, repo: &RepoRef) -> Result { let mut cursor = None; let mut nodes = Vec::new(); + let mut repository_id: Option = None; loop { let request = GraphqlRequest { @@ -156,7 +175,7 @@ impl Provider for GithubProvider { let response: GraphqlEnvelope = serde_json::from_value(response) .map_err(|error| ProviderError::Parse(error.to_string()))?; - let connection = response + let repository = response .data .ok_or_else(|| ProviderError::Parse("missing data.repository.issues".into())) .and_then(|data| { @@ -164,8 +183,17 @@ impl Provider for GithubProvider { .map_err(|error| ProviderError::Parse(error.to_string())) })? .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 { @@ -176,9 +204,7 @@ impl Provider for GithubProvider { })?); } - let mut issues = map_issues(nodes); - issues.sort_by_key(|issue| issue.number); - Ok(issues) + Ok(map_snapshot(repository_id, nodes)) } } @@ -189,8 +215,9 @@ fn map_octocrab_error(error: octocrab::Error) -> ProviderError { } } -fn map_issues(nodes: Vec) -> Vec { +fn map_snapshot(repository_id: 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 { @@ -207,6 +234,16 @@ fn map_issues(nodes: Vec) -> Vec { blocked_by.dedup(); inversions.extend(refs.blocks.into_iter().map(|target| (node.number, target))); + history.push(IssueSyncMetadata { + issue_id: node.id.clone(), + number: node.number, + created_at: node.created_at.timestamp(), + updated_at: node.updated_at.timestamp(), + milestone: node.milestone.as_ref().map(|milestone| MilestoneRef { + id: milestone.id.clone(), + title: milestone.title.clone(), + }), + }); issues.push(RawIssue { number: node.number, parent_issue: node.parent.map(|parent| parent.number), @@ -251,8 +288,14 @@ fn map_issues(nodes: Vec) -> Vec { 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 { + repository_id, + issues, + history, + } } #[derive(Serialize)] @@ -280,6 +323,7 @@ struct GraphqlData { #[derive(Deserialize)] struct Repository { + id: String, issues: IssueConnection, } @@ -300,7 +344,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, @@ -330,8 +377,9 @@ struct Assignee { login: String, } -#[derive(Deserialize)] +#[derive(Clone, Deserialize)] struct Milestone { + id: String, title: String, } diff --git a/crates/github/tests/sync_test.rs b/crates/github/tests/sync_test.rs index 71a9c92..1a0db7c 100644 --- a/crates/github/tests/sync_test.rs +++ b/crates/github/tests/sync_test.rs @@ -1,5 +1,5 @@ use serde_json::{Value, json}; -use stellr_core::{IssueState, Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{IssueState, MilestoneRef, Provider, ProviderError, RawIssue, RepoRef}; use stellr_github::sync::GithubProvider; use wiremock::matchers::{body_partial_json, body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -19,6 +19,7 @@ fn page_with_pagination(nodes: Value, has_next_page: bool, end_cursor: Option<&s json!({ "data": { "repository": { + "id": "R_repo", "issues": { "pageInfo": { "hasNextPage": has_next_page, @@ -91,6 +92,69 @@ async fn fetch_follows_pagination_until_the_repository_is_complete() { ); } +#[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).set_body_json(json!({ + "data": { + "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.repository_id.as_deref(), Some("R_repo")); + 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, + Some(MilestoneRef { + id: "M_1".into(), + title: "M1".into(), + }) + ); +} + #[tokio::test] async fn fetch_maps_a_rejected_token_to_auth() { let server = MockServer::start().await; @@ -176,7 +240,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, @@ -188,7 +255,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..22f98d3 --- /dev/null +++ b/crates/history/src/lib.rs @@ -0,0 +1,3 @@ +pub mod store; + +pub use store::{HistoryStore, RepositorySeed, StoreError}; diff --git a/crates/history/src/store.rs b/crates/history/src/store.rs new file mode 100644 index 0000000..38b3a55 --- /dev/null +++ b/crates/history/src/store.rs @@ -0,0 +1,357 @@ +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 = 1; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepositorySeed { + pub space_id: String, + pub provider_repository_id: String, + pub verified_through: i64, + pub issues: Vec, +} + +#[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 total_issues = i64::try_from(issues.len())?; + 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 + ) VALUES (?1, ?2, 'complete', ?3, ?3, ?4, NULL, NULL) + ON CONFLICT(space_id) DO UPDATE SET + provider_repository_id = excluded.provider_repository_id, + import_state = excluded.import_state, + total_issues = excluded.total_issues, + completed_issues = excluded.completed_issues, + verified_through = excluded.verified_through, + diagnostic = NULL, + resume_at = NULL", + params![ + seed.space_id, + seed.provider_repository_id, + total_issues, + seed.verified_through + ], + )?; + + for issue in issues { + let issue_number = i64::try_from(issue.number)?; + let (milestone_id, milestone_title) = issue + .milestone + .as_ref() + .map(|milestone| (Some(milestone.id.as_str()), Some(milestone.title.as_str()))) + .unwrap_or((None, None)); + 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, 1) + ON CONFLICT(space_id, provider_issue_id) DO UPDATE SET + issue_number = excluded.issue_number, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + milestone_id = excluded.milestone_id, + milestone_title = excluded.milestone_title", + params![ + seed.space_id, + issue.issue_id, + issue_number, + issue.created_at, + issue.updated_at, + milestone_id, + milestone_title + ], + )?; + + 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)? + ], + )?; + } + + 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 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 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 version = + connection.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?; + if version == SCHEMA_VERSION { + return Ok(()); + } + if version != 0 { + return Err(StoreError::UnsupportedSchema(version)); + } + 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", 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 { + match state { + "unavailable" => Ok(HistoryImportState::Unavailable), + "building" => Ok(HistoryImportState::Building), + "complete" => Ok(HistoryImportState::Complete), + "delayed" => Ok(HistoryImportState::Delayed), + "rate_limited" => Ok(HistoryImportState::RateLimited), + "failed" => Ok(HistoryImportState::Failed), + _ => Err(StoreError::Invalid(format!( + "unknown history import state: {state}" + ))), + } +} + +#[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..421eb3c --- /dev/null +++ b/crates/history/tests/store_test.rs @@ -0,0 +1,89 @@ +use stellr_core::{HistoryEventKind, HistoryImportState, IssueSyncMetadata, MilestoneRef}; +use stellr_history::{HistoryStore, 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: 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, + 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 == "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, + 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()); +} 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 b4ba119..d536f26 100644 --- a/crates/server/src/poll.rs +++ b/crates/server/src/poll.rs @@ -1,8 +1,9 @@ use std::{sync::Arc, time::Duration}; use chrono::Utc; -use stellr_core::{Model, Provider, SpaceModel, derive}; +use stellr_core::{HistoryImportState, HistorySummary, Model, Provider, SpaceModel, derive}; use stellr_github::cache::{Cache, Snapshot}; +use stellr_history::RepositorySeed; use crate::{spaces::SpaceEntry, state::AppState}; @@ -98,7 +99,7 @@ async fn sync_spaces(state: &AppState, provider: &(dyn Provider + Send + Sync), let entries = state.spaces.lock().await.entries().to_vec(); let mut spaces = Vec::with_capacity(entries.len()); for entry in entries { - spaces.push(sync_space(&entry, provider, cache).await); + spaces.push(sync_space(&entry, provider, cache, &state.history).await); } state.hub.send_replace(Model { spaces }); } @@ -107,36 +108,94 @@ async fn sync_space( entry: &SpaceEntry, provider: &(dyn Provider + Send + Sync), cache: &Cache, + history_store: &stellr_history::HistoryStore, ) -> SpaceModel { - match provider.fetch(&entry.repo).await { - Ok(issues) => { + match provider.fetch_snapshot(&entry.repo).await { + Ok(snapshot) => { let synced_at = Utc::now().timestamp(); // A successful provider sync is fresh even if its fallback cache cannot be updated. let _ = cache.store( &entry.repo, &Snapshot { - issues: issues.clone(), + issues: snapshot.issues.clone(), synced_at, }, ); - model(entry, issues, Some(synced_at), false, None) + let history = history_after_snapshot(entry, &snapshot, history_store, synced_at); + model( + entry, + snapshot.issues, + Some(synced_at), + false, + None, + history, + ) } Err(error) => { let snapshot = cache.load(&entry.repo); let (issues, synced_at) = snapshot .map(|snapshot| (snapshot.issues, Some(snapshot.synced_at))) .unwrap_or_default(); - model(entry, issues, synced_at, true, Some(error.to_string())) + let history = history_store + .summary(&entry.id) + .unwrap_or_else(|history_error| Some(failed_history(history_error.to_string()))) + .unwrap_or_default(); + model( + entry, + issues, + synced_at, + true, + Some(error.to_string()), + history, + ) } } } +fn history_after_snapshot( + entry: &SpaceEntry, + snapshot: &stellr_core::ProviderSnapshot, + store: &stellr_history::HistoryStore, + verified_through: i64, +) -> 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(); + }; + 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, + 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( entry: &SpaceEntry, issues: Vec, synced_at: Option, stale: bool, error: Option, + history: HistorySummary, ) -> SpaceModel { SpaceModel { id: entry.id.clone(), @@ -146,5 +205,6 @@ fn model( synced_at, stale, error, + history, } } diff --git a/crates/server/src/routes.rs b/crates/server/src/routes.rs index b7d92ef..7706d5a 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, 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,55 @@ 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 = 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(); + } + }; + 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 diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 2a1e516..7346140 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use stellr_core::Model; +use stellr_history::HistoryStore; use tokio::sync::{Mutex, Notify, watch}; use crate::spaces::SpaceStore; @@ -11,4 +12,5 @@ pub struct AppState { pub token: Option, 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 13922d9..ec63b7f 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -8,8 +8,12 @@ use std::{ use futures_util::{SinkExt, StreamExt}; use serde_json::json; -use stellr_core::{IssueState, Model, Provider, ProviderError, RawIssue, RepoRef, SpaceModel}; +use stellr_core::{ + HistoryImportState, IssueState, IssueSyncMetadata, Model, Provider, ProviderError, + ProviderSnapshot, RawIssue, RepoRef, SpaceModel, +}; use stellr_github::cache::{Cache, Snapshot}; +use stellr_history::{HistoryStore, RepositorySeed}; use stellr_server::{ poll::spawn_poller, spaces::{SpaceEntry, SpaceStore}, @@ -52,6 +56,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(), }) } @@ -69,6 +74,7 @@ fn model_with_space(id: &str) -> Model { synced_at: None, stale: false, error: None, + history: Default::default(), }], } } @@ -148,6 +154,171 @@ impl Provider for StubProvider { } } +#[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, + 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); +} + +struct HistorySnapshotProvider; + +#[async_trait::async_trait] +impl Provider for HistorySnapshotProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + unreachable!("the poller should consume the richer provider snapshot") + } + + async fn fetch_snapshot(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot { + repository_id: Some("R_repo".into()), + issues: 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(), + }], + history: vec![IssueSyncMetadata { + issue_id: "I_78".into(), + number: 78, + created_at: 100, + updated_at: 200, + milestone: None, + }], + }) + } +} + +#[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), + ); + tokio::time::timeout(Duration::from_secs(2), receiver.changed()) + .await + .expect("the startup poll should publish") + .expect("the model hub should stay open"); + + let model = receiver.borrow().clone(); + 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(); +} + struct FailingProvider; #[async_trait::async_trait] @@ -189,6 +360,7 @@ async fn add_repo_space_then_refresh_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(), @@ -298,6 +470,7 @@ async fn failed_sync_publishes_the_cached_model_as_stale_with_the_error() { 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(), @@ -358,6 +531,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, @@ -387,6 +561,7 @@ async fn delete_space_removes_the_persisted_entry() { 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; let client = reqwest::Client::new(); @@ -444,6 +619,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; @@ -487,6 +663,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 2d0e869..a363064 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, RawIssue, 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/web/src/App.svelte b/web/src/App.svelte index 53f1af9..7949824 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -5,9 +5,12 @@ 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 { 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' @@ -71,6 +74,15 @@ ) const activeSpace = $derived(resolvedRoute.space) const activeStar = $derived(resolvedRoute.star) + let historyEvents = $state.raw([]) + let historyPlayhead = $state(null) + 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>({}) @@ -84,6 +96,29 @@ (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 + requestedHistoryKey = null + } + const summary = space?.history + if (space === null || summary?.state !== 'complete') return + const key = `${space.id}:${summary.revision}` + if (requestedHistoryKey === key) return + requestedHistoryKey = key + void fetchHistory(space.id) + .then((response) => { + if (historySpaceId !== space.id) return + historyEvents = response.events + }) + .catch(() => { + if (requestedHistoryKey === key) requestedHistoryKey = null + }) + }) + async function pollNativeRoute(): Promise { if (nativeRouteBusy) return nativeRouteBusy = true @@ -343,13 +378,22 @@ title="Opening observatory" description="Loading cached spaces and the latest GitHub issue state." /> - {:else if activeSpace} + {:else if activeSpace && temporalSpace} route.go(activeSpace.id, issueNumber)} + bottomInset={activeSpace.history === undefined ? 16 : 88} /> + {#if activeSpace.history !== undefined} + (historyPlayhead = playhead)} + /> + {/if} {:else} { } describe('App issue routing', () => { + it('loads complete history once and scrubs renderer visibility without another request', async () => { + 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 { 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, + ) + }) + it('distinguishes runtime loading from an authoritative empty model', () => { const { target, socket } = mountApp() diff --git a/web/src/lib/StarMap.svelte b/web/src/lib/StarMap.svelte index 474dfa1..95a623a 100644 --- a/web/src/lib/StarMap.svelte +++ b/web/src/lib/StarMap.svelte @@ -1,5 +1,6 @@ + +
+ {#if summary.state === 'building'} + + Building history · {summary.completed_issues}/{summary.total_issues} issues + + {:else if summary.state === 'complete' && !hasHistory} + No issue history + {:else if summary.state !== 'complete'} + {summary.diagnostic ?? 'History unavailable'} + {/if} + + {label} + + {#if playhead !== null} + + {/if} +
+ + diff --git a/web/src/lib/TemporalTimeline.test.ts b/web/src/lib/TemporalTimeline.test.ts new file mode 100644 index 0000000..7c0939a --- /dev/null +++ b/web/src/lib/TemporalTimeline.test.ts @@ -0,0 +1,87 @@ +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 = '' +}) + +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, + }, +] + +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 }, + events: [], + playhead: null, + }) + + expect(target.querySelector('input[type="range"]')).toHaveProperty('disabled', true) + expect(target.textContent).toContain('Building history · 3/10 issues') + }) + + 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 and return-to-now 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) + + const pastTarget = render({ summary: complete, events, playhead: 100, change }) + pastTarget.querySelector('button')!.click() + expect(change).toHaveBeenLastCalledWith(null) + }) +}) 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..8327383 --- /dev/null +++ b/web/src/lib/history.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { 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.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, 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]]) + }) +}) diff --git a/web/src/lib/history.ts b/web/src/lib/history.ts new file mode 100644 index 0000000..6186e06 --- /dev/null +++ b/web/src/lib/history.ts @@ -0,0 +1,73 @@ +import type { SpaceModel, Star, Status } from './model' + +export interface MilestoneRef { + id: string + 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 { + stars: TemporalStar[] +} + +export function projectTemporalSpace( + space: SpaceModel, + events: HistoryEvent[], + playhead: number | null, +): TemporalSpace { + if (playhead === null) { + return { + ...space, + stars: space.stars.map((star) => ({ + ...star, + live_status: star.status, + temporal_visible: true, + })), + } + } + + const creationByIssue = new Map>() + for (const event of events) { + if (event.kind === 'issue_created') creationByIssue.set(event.issue_number, event) + } + + return { + ...space, + stars: space.stars.map((star) => { + const creation = creationByIssue.get(star.number) + return { + ...star, + live_status: star.status, + status: 'open', + milestone: creation?.milestone?.title ?? null, + temporal_visible: creation !== undefined && creation.occurred_at <= playhead, + } + }), + } +} diff --git a/web/src/lib/model.ts b/web/src/lib/model.ts index e3f7b3f..35eee26 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 @@ -21,6 +40,7 @@ export interface SpaceModel { synced_at: number | null stale: boolean error: string | null + history?: HistorySummary } export interface Model { diff --git a/web/src/lib/starmap/adapt.ts b/web/src/lib/starmap/adapt.ts index f8b3c05..c8e30f1 100644 --- a/web/src/lib/starmap/adapt.ts +++ b/web/src/lib/starmap/adapt.ts @@ -1,8 +1,11 @@ import type { SpaceModel } from '../model' +import type { TemporalSpace } from '../history' import type { Ticket } from './model' -export function toRendererModel(space: SpaceModel): Ticket[] { - return space.stars.map((star) => ({ +export function toRendererModel(space: SpaceModel | TemporalSpace): Ticket[] { + return space.stars.map((star) => { + const liveStatus = 'live_status' in star ? star.live_status : star.status + return { num: star.number, slug: String(star.number), title: star.title, @@ -12,7 +15,10 @@ export function toRendererModel(space: SpaceModel): Ticket[] { parentIssue: star.parent_issue, frontier: star.status === 'frontier', readyForAgent: - star.status === 'frontier' && + liveStatus === 'frontier' && star.labels.some((label) => label.toLowerCase() === 'ready-for-agent'), - })) + visible: 'temporal_visible' in star ? star.temporal_visible : true, + focusStatus: liveStatus, + } + }) } 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 dcbd1b1..a0d33df 100644 --- a/web/src/lib/starmap/model.ts +++ b/web/src/lib/starmap/model.ts @@ -18,6 +18,8 @@ export interface Ticket { parentIssue: number | null frontier: boolean readyForAgent?: boolean + visible?: boolean + focusStatus?: TicketStatus } export interface Map { diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index 06ba59d..a4c59ca 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -402,6 +402,35 @@ describe('the session overlay on the seam', () => { }) }) +describe('temporal visibility on the renderer seam', () => { + 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() + }) +}) + // 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 diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index 087ea74..4d9bb55 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -53,6 +53,7 @@ interface Node { title: string type: string parentIssue: number | null + visible: boolean vstate: VisualState // 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. @@ -352,6 +353,7 @@ export class StarMap { if (!n) continue n.title = t.title n.type = t.type + n.visible = t.visible !== false const vstate = visualState(t) const sstate = sessions[t.num] ?? null if (vstate !== n.vstate || sstate !== n.sstate) { @@ -385,6 +387,7 @@ export class StarMap { title: t.title, type: t.type, parentIssue: t.parentIssue, + visible: t.visible !== false, vstate: visualState(t), sstate: sessions[t.num] ?? null, x: p.x, @@ -404,12 +407,17 @@ export class StarMap { const byNum = new Map(tickets.map((ticket) => [ticket.num, ticket])) const edges = workflowEdges(tickets) const reverseEdges = reverseEdgeKeys(edges) - this.#edges = edges.map((edge) => ({ + 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)), - })) + })) } // --- seam: emit selection ------------------------------------------------- @@ -541,7 +549,7 @@ 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 } } @@ -555,6 +563,7 @@ export class StarMap { selectAtScreen(sx: number, sy: number): number | null { let hit: Node | null = null 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 r = Math.max(14, this.#radius(n) * this.#cam.s + 10) @@ -904,6 +913,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.#clock) @@ -1296,6 +1306,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 From 35f999c522eb0c377351471e60bfbeaf4b37a553 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:48:23 -0500 Subject: [PATCH 04/18] feat(history): replay issue close and reopen activity Closes #79 Refs #78 --- CHANGELOG.md | 3 + crates/app/tests/application_process_test.rs | 35 ++- crates/github/src/history.rs | 212 +++++++++++++++ crates/github/src/lib.rs | 1 + crates/github/src/sync.rs | 8 + crates/github/tests/history_test.rs | 134 ++++++++++ crates/history/src/lib.rs | 2 +- crates/history/src/store.rs | 264 +++++++++++++++++-- crates/history/tests/store_test.rs | 161 ++++++++++- crates/server/src/poll.rs | 76 +++++- crates/server/src/routes.rs | 22 +- crates/server/tests/api_test.rs | 149 ++++++++++- web/src/lib/history.test.ts | 57 ++++ web/src/lib/history.ts | 23 +- 14 files changed, 1100 insertions(+), 47 deletions(-) create mode 100644 crates/github/src/history.rs create mode 100644 crates/github/tests/history_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d36860..c70ff0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- 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. diff --git a/crates/app/tests/application_process_test.rs b/crates/app/tests/application_process_test.rs index f2ed9bc..06e9e9c 100644 --- a/crates/app/tests/application_process_test.rs +++ b/crates/app/tests/application_process_test.rs @@ -1,7 +1,7 @@ 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,7 +30,38 @@ 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": { "repository": { diff --git a/crates/github/src/history.rs b/crates/github/src/history.rs new file mode 100644 index 0000000..6222009 --- /dev/null +++ b/crates/github/src/history.rs @@ -0,0 +1,212 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use stellr_core::{ + HistoryEvent, HistoryEventKind, HistoryPage, HistoryPageRequest, 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] + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + __typename + ... on ClosedEvent { id createdAt } + ... on ReopenedEvent { id createdAt } + } + } + } + } +} +"#; + +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(); + for node in issue.timeline_items.nodes { + let kind = match node.typename.as_str() { + "ClosedEvent" => HistoryEventKind::IssueClosed, + "ReopenedEvent" => HistoryEventKind::IssueReopened, + _ => 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 { + 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, + }); + } + } + events.sort(); + + let page_info = issue.timeline_items.page_info; + let next_cursor = if page_info.has_next_page { + Some(page_info.end_cursor.ok_or_else(|| { + contextual_message( + request, + "advancing history page", + "missing end cursor for next page", + ) + })?) + } else { + None + }; + Ok(HistoryPage { + events, + next_cursor, + complete: !page_info.has_next_page, + }) +} + +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, + nodes: Vec, +} + +#[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, +} 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 b6e593e..191b997 100644 --- a/crates/github/src/sync.rs +++ b/crates/github/src/sync.rs @@ -152,6 +152,14 @@ impl Provider for GithubProvider { 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 { diff --git a/crates/github/tests/history_test.rs b/crates/github/tests/history_test.rs new file mode 100644 index 0000000..ef36bd6 --- /dev/null +++ b/crates/github/tests/history_test.rs @@ -0,0 +1,134 @@ +use serde_json::{Value, json}; +use stellr_core::{HistoryEventKind, HistoryPageRequest, 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_lifecycle_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" }, + "nodes": [ + { + "__typename": "ReopenedEvent", + "id": "E_reopen", + "createdAt": "2026-08-04T13:10:00Z" + }, + { + "__typename": "AssignedEvent", + "id": "E_ignore", + "createdAt": "2026-08-04T13:05:00Z" + }, + { + "__typename": "ClosedEvent", + "id": "E_close", + "createdAt": "2026-08-04T13:00:00Z" + }, + { + "__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]")); + 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.as_deref(), Some("CUR2")); + assert!(!page.complete); + assert_eq!(page.events.len(), 2); + 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 + )); +} + +#[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 }, + "nodes": [{ + "__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:?}"), + } +} diff --git a/crates/history/src/lib.rs b/crates/history/src/lib.rs index 22f98d3..eda4b6b 100644 --- a/crates/history/src/lib.rs +++ b/crates/history/src/lib.rs @@ -1,3 +1,3 @@ pub mod store; -pub use store::{HistoryStore, RepositorySeed, StoreError}; +pub use store::{HistoryStore, PageCheckpoint, PendingHistoryIssue, RepositorySeed, StoreError}; diff --git a/crates/history/src/store.rs b/crates/history/src/store.rs index 38b3a55..442da5a 100644 --- a/crates/history/src/store.rs +++ b/crates/history/src/store.rs @@ -8,16 +8,34 @@ use stellr_core::{ HistoryEvent, HistoryEventKind, HistoryImportState, HistorySummary, IssueSyncMetadata, }; -const SCHEMA_VERSION: i64 = 1; +const SCHEMA_VERSION: i64 = 2; #[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 complete: bool, +} + #[derive(Clone)] pub struct HistoryStore { connection: Arc>, @@ -56,29 +74,43 @@ impl HistoryStore { .cmp(&(right.created_at, HistoryEvent::creation_id(&right.issue_id))) }); let total_issues = i64::try_from(issues.len())?; + let timeline_required = seed.timeline_required && total_issues > 0; + let import_state = if timeline_required { + "building" + } else { + "complete" + }; + let completed_issues = if timeline_required { 0 } else { total_issues }; + let verified_through = (!timeline_required).then_some(seed.verified_through); 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 - ) VALUES (?1, ?2, 'complete', ?3, ?3, ?4, NULL, NULL) - ON CONFLICT(space_id) DO UPDATE SET - provider_repository_id = excluded.provider_repository_id, - import_state = excluded.import_state, - total_issues = excluded.total_issues, - completed_issues = excluded.completed_issues, - verified_through = excluded.verified_through, - diagnostic = NULL, - resume_at = NULL", + completed_issues, verified_through, diagnostic, resume_at, cutoff + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, ?7) + ON CONFLICT(space_id) DO NOTHING", params![ seed.space_id, seed.provider_repository_id, + import_state, total_issues, + completed_issues, + verified_through, seed.verified_through ], )?; + 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(), + )); + } for issue in issues { let issue_number = i64::try_from(issue.number)?; @@ -91,7 +123,7 @@ impl HistoryStore { "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, 1) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8) ON CONFLICT(space_id, provider_issue_id) DO UPDATE SET issue_number = excluded.issue_number, created_at = excluded.created_at, @@ -105,7 +137,8 @@ impl HistoryStore { issue.created_at, issue.updated_at, milestone_id, - milestone_title + milestone_title, + !timeline_required ], )?; @@ -193,6 +226,173 @@ impl HistoryStore { 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: i64 = transaction.query_row( + "SELECT issue_number FROM issues + WHERE space_id = ?1 AND provider_issue_id = ?2", + params![checkpoint.space_id, checkpoint.issue_id], + |row| row.get(0), + )?; + 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)? + ], + )?; + } + + 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, + checkpoint.next_cursor, + checkpoint.complete + ], + )?; + let (completed, total): (i64, i64) = transaction.query_row( + "SELECT SUM(CASE WHEN complete = 1 THEN 1 ELSE 0 END), COUNT(*) + FROM issues WHERE space_id = ?1", + params![checkpoint.space_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let repository_complete = completed == total; + transaction.execute( + "UPDATE repositories + SET import_state = ?2, + completed_issues = ?3, + verified_through = ?4, + diagnostic = NULL, + resume_at = NULL + WHERE space_id = ?1", + params![ + checkpoint.space_id, + if repository_complete { + "complete" + } else { + "building" + }, + 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 = 'failed', diagnostic = ?2, resume_at = NULL + WHERE space_id = ?1", + params![space_id, 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 remove_repository(&self, space_id: &str) -> Result { let connection = self.connection()?; Ok(connection.execute( @@ -226,17 +426,15 @@ fn validate_seed(seed: &RepositorySeed) -> Result<(), StoreError> { } fn migrate(connection: &mut Connection) -> Result<(), StoreError> { - let version = + let mut version = connection.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?; - if version == SCHEMA_VERSION { - return Ok(()); - } - if version != 0 { + if version > SCHEMA_VERSION { return Err(StoreError::UnsupportedSchema(version)); } - let transaction = connection.transaction()?; - transaction.execute_batch( - "CREATE TABLE repositories ( + 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, @@ -270,9 +468,27 @@ fn migrate(connection: &mut Connection) -> Result<(), StoreError> { ); CREATE INDEX events_space_time ON events(space_id, occurred_at, provider_event_id);", - )?; - transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; - transaction.commit()?; + )?; + 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", SCHEMA_VERSION)?; + transaction.commit()?; + } Ok(()) } diff --git a/crates/history/tests/store_test.rs b/crates/history/tests/store_test.rs index 421eb3c..d40b8fd 100644 --- a/crates/history/tests/store_test.rs +++ b/crates/history/tests/store_test.rs @@ -1,5 +1,7 @@ -use stellr_core::{HistoryEventKind, HistoryImportState, IssueSyncMetadata, MilestoneRef}; -use stellr_history::{HistoryStore, RepositorySeed}; +use stellr_core::{ + HistoryEvent, HistoryEventKind, HistoryImportState, IssueSyncMetadata, MilestoneRef, +}; +use stellr_history::{HistoryStore, PageCheckpoint, RepositorySeed}; fn issue( issue_id: &str, @@ -28,6 +30,7 @@ fn initializes_creation_history_idempotently_and_reads_ordered_deltas() { 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"))), @@ -75,6 +78,7 @@ fn removal_is_explicit_and_scoped_to_one_repository() { 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(); @@ -87,3 +91,156 @@ fn removal_is_explicit_and_scoped_to_one_repository() { 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), + ], + next_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 complete = store + .checkpoint_page(&PageCheckpoint { + space_id: "octocat-hello".into(), + issue_id: "I_1".into(), + events: vec![], + next_cursor: None, + complete: true, + }) + .unwrap(); + let events = store.events_after("octocat-hello", 0).unwrap(); + + 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_reopen"] + ); +} + +#[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); +} diff --git a/crates/server/src/poll.rs b/crates/server/src/poll.rs index d536f26..4c1fca8 100644 --- a/crates/server/src/poll.rs +++ b/crates/server/src/poll.rs @@ -1,7 +1,9 @@ use std::{sync::Arc, time::Duration}; use chrono::Utc; -use stellr_core::{HistoryImportState, HistorySummary, Model, Provider, SpaceModel, derive}; +use stellr_core::{ + HistoryImportState, HistoryPageRequest, HistorySummary, Model, Provider, SpaceModel, derive, +}; use stellr_github::cache::{Cache, Snapshot}; use stellr_history::RepositorySeed; @@ -98,10 +100,77 @@ async fn run_poller( async fn sync_spaces(state: &AppState, provider: &(dyn Provider + Send + Sync), cache: &Cache) { let entries = state.spaces.lock().await.entries().to_vec(); let mut spaces = Vec::with_capacity(entries.len()); - for entry in entries { - spaces.push(sync_space(&entry, provider, cache, &state.history).await); + for entry in &entries { + spaces.push(sync_space(entry, provider, cache, &state.history).await); } state.hub.send_replace(Model { spaces }); + for entry in &entries { + import_pending_history(state, provider, entry).await; + } +} + +async fn import_pending_history( + state: &AppState, + provider: &(dyn Provider + Send + Sync), + entry: &SpaceEntry, +) { + 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 = 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; + } + }; + 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, + 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_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; + }; + space.history = summary; + state.hub.send_replace(model); } async fn sync_space( @@ -176,6 +245,7 @@ fn history_after_snapshot( 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())) diff --git a/crates/server/src/routes.rs b/crates/server/src/routes.rs index 7706d5a..4c5d883 100644 --- a/crates/server/src/routes.rs +++ b/crates/server/src/routes.rs @@ -12,7 +12,7 @@ use axum::{ routing::{delete, get, post}, }; use serde::{Deserialize, Serialize}; -use stellr_core::{HistoryEvent, HistorySummary, Model}; +use stellr_core::{HistoryEvent, HistoryImportState, HistorySummary, Model}; use subtle::ConstantTimeEq; use crate::{ @@ -75,15 +75,19 @@ async fn history( .into_response(); } }; - let events = 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(); + let events = if summary.state == HistoryImportState::Complete { + 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() } diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index ec63b7f..050c680 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -9,8 +9,9 @@ use std::{ use futures_util::{SinkExt, StreamExt}; use serde_json::json; use stellr_core::{ - HistoryImportState, IssueState, IssueSyncMetadata, 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, RepositorySeed}; @@ -175,6 +176,7 @@ async fn history_endpoint_is_authenticated_scoped_and_sequence_incremental() { 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(), @@ -274,6 +276,87 @@ impl Provider for HistorySnapshotProvider { }], }) } + + async fn fetch_history_page( + &self, + _repo: &RepoRef, + _request: &HistoryPageRequest, + ) -> Result { + Ok(HistoryPage { + events: vec![], + next_cursor: None, + complete: true, + }) + } +} + +struct LifecycleSnapshotProvider(AtomicUsize); + +#[async_trait::async_trait] +impl Provider for LifecycleSnapshotProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + unreachable!("the poller should consume the richer provider snapshot") + } + + async fn fetch_snapshot(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot { + repository_id: Some("R_repo".into()), + issues: 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(), + }], + history: 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, + complete: true, + }) + } } #[tokio::test] @@ -319,6 +402,68 @@ async fn successful_current_sync_seeds_creation_history_and_publishes_its_summar 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(2), 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(); +} + struct FailingProvider; #[async_trait::async_trait] diff --git a/web/src/lib/history.test.ts b/web/src/lib/history.test.ts index 8327383..69ba2a2 100644 --- a/web/src/lib/history.test.ts +++ b/web/src/lib/history.test.ts @@ -101,3 +101,60 @@ describe('temporal creation projection', () => { 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') + }) +}) diff --git a/web/src/lib/history.ts b/web/src/lib/history.ts index 6186e06..943e822 100644 --- a/web/src/lib/history.ts +++ b/web/src/lib/history.ts @@ -53,8 +53,23 @@ export function projectTemporalSpace( } const creationByIssue = new Map>() - for (const event of events) { - if (event.kind === 'issue_created') creationByIssue.set(event.issue_number, event) + const statusByIssue = 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') + } else if (event.kind === 'issue_closed') { + statusByIssue.set(event.issue_number, 'resolved') + } else if (event.kind === 'issue_reopened') { + statusByIssue.set(event.issue_number, 'open') + } } return { @@ -64,9 +79,9 @@ export function projectTemporalSpace( return { ...star, live_status: star.status, - status: 'open', + status: statusByIssue.get(star.number) ?? 'open', milestone: creation?.milestone?.title ?? null, - temporal_visible: creation !== undefined && creation.occurred_at <= playhead, + temporal_visible: creation !== undefined, } }), } From e7d2525b13cb02f46a9d4947789c166968578135 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:03:34 -0500 Subject: [PATCH 05/18] feat(history): replay milestone membership without moving stars Closes #81 Refs #79 --- CHANGELOG.md | 3 + crates/core/src/history.rs | 2 +- crates/core/tests/model_contract_test.rs | 2 +- crates/github/src/history.rs | 30 +++++++- crates/github/src/sync.rs | 2 +- crates/github/tests/history_test.rs | 89 ++++++++++++++++++++++-- crates/github/tests/sync_test.rs | 2 +- crates/history/src/store.rs | 2 +- crates/history/tests/store_test.rs | 27 ++++++- web/src/lib/StarMap.test.ts | 30 ++++++++ web/src/lib/history.test.ts | 76 ++++++++++++++++++++ web/src/lib/history.ts | 8 ++- web/src/lib/starmap/adapt.test.ts | 9 +++ web/src/lib/starmap/adapt.ts | 29 ++++---- web/src/lib/starmap/model.ts | 1 + web/src/lib/starmap/starmap.test.ts | 41 +++++++++++ web/src/lib/starmap/starmap.ts | 67 ++++++++++++++++++ 17 files changed, 390 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c70ff0d..3a8dfc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- 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. diff --git a/crates/core/src/history.rs b/crates/core/src/history.rs index 0efcfe8..bc5ff4f 100644 --- a/crates/core/src/history.rs +++ b/crates/core/src/history.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MilestoneRef { - pub id: String, + pub id: Option, pub title: String, } diff --git a/crates/core/tests/model_contract_test.rs b/crates/core/tests/model_contract_test.rs index 3d48560..3f68c48 100644 --- a/crates/core/tests/model_contract_test.rs +++ b/crates/core/tests/model_contract_test.rs @@ -43,7 +43,7 @@ fn creation_events_serialize_with_minimal_provider_evidence() { occurred_at: 1_754_300_000, kind: HistoryEventKind::IssueCreated { milestone: Some(MilestoneRef { - id: "M_v1".into(), + id: Some("M_v1".into()), title: "v1".into(), }), }, diff --git a/crates/github/src/history.rs b/crates/github/src/history.rs index 6222009..14bfade 100644 --- a/crates/github/src/history.rs +++ b/crates/github/src/history.rs @@ -1,7 +1,8 @@ use chrono::DateTime; use serde::{Deserialize, Serialize}; use stellr_core::{ - HistoryEvent, HistoryEventKind, HistoryPage, HistoryPageRequest, ProviderError, RepoRef, + HistoryEvent, HistoryEventKind, HistoryPage, HistoryPageRequest, MilestoneRef, ProviderError, + RepoRef, }; use crate::sync::GithubGraphqlClient; @@ -20,7 +21,7 @@ query FetchIssueHistory( timelineItems( first: 100 after: $cursor - itemTypes: [CLOSED_EVENT, REOPENED_EVENT] + itemTypes: [CLOSED_EVENT, REOPENED_EVENT, DEMILESTONED_EVENT, MILESTONED_EVENT] ) { pageInfo { hasNextPage @@ -30,6 +31,8 @@ query FetchIssueHistory( __typename ... on ClosedEvent { id createdAt } ... on ReopenedEvent { id createdAt } + ... on DemilestonedEvent { id createdAt milestoneTitle } + ... on MilestonedEvent { id createdAt milestoneTitle } } } } @@ -76,6 +79,14 @@ pub(crate) async fn fetch_history_page( 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)?), + }, _ => continue, }; let provider_event_id = node.id.ok_or_else(|| { @@ -135,6 +146,20 @@ pub(crate) async fn fetch_history_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, @@ -209,4 +234,5 @@ struct TimelineNode { typename: String, id: Option, created_at: Option, + milestone_title: Option, } diff --git a/crates/github/src/sync.rs b/crates/github/src/sync.rs index 191b997..4c3c0b3 100644 --- a/crates/github/src/sync.rs +++ b/crates/github/src/sync.rs @@ -248,7 +248,7 @@ fn map_snapshot(repository_id: Option, nodes: Vec) -> Provide created_at: node.created_at.timestamp(), updated_at: node.updated_at.timestamp(), milestone: node.milestone.as_ref().map(|milestone| MilestoneRef { - id: milestone.id.clone(), + id: Some(milestone.id.clone()), title: milestone.title.clone(), }), }); diff --git a/crates/github/tests/history_test.rs b/crates/github/tests/history_test.rs index ef36bd6..83b247e 100644 --- a/crates/github/tests/history_test.rs +++ b/crates/github/tests/history_test.rs @@ -1,5 +1,7 @@ use serde_json::{Value, json}; -use stellr_core::{HistoryEventKind, HistoryPageRequest, Provider, ProviderError, RepoRef}; +use stellr_core::{ + HistoryEventKind, HistoryPageRequest, MilestoneRef, Provider, ProviderError, RepoRef, +}; use stellr_github::sync::GithubProvider; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -21,7 +23,7 @@ fn request(cursor: Option<&str>) -> HistoryPageRequest { } #[tokio::test] -async fn fetches_one_targeted_lifecycle_page_and_normalizes_tracked_events() { +async fn fetches_one_targeted_history_page_and_normalizes_tracked_events() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/graphql")) @@ -49,6 +51,18 @@ async fn fetches_one_targeted_lifecycle_page_and_normalizes_tracked_events() { "id": "E_close", "createdAt": "2026-08-04T13:00:00Z" }, + { + "__typename": "DemilestonedEvent", + "id": "E_demilestone", + "createdAt": "2026-08-04T13:15:00Z", + "milestoneTitle": "Alpha" + }, + { + "__typename": "MilestonedEvent", + "id": "E_milestone", + "createdAt": "2026-08-04T13:15:00Z", + "milestoneTitle": "Beta" + }, { "__typename": "ClosedEvent", "id": "E_after_cutoff", @@ -74,14 +88,16 @@ async fn fetches_one_targeted_lifecycle_page_and_normalizes_tracked_events() { let query = body["query"].as_str().unwrap(); assert!(query.contains("issue(number: $number)")); - assert!(query.contains("itemTypes: [CLOSED_EVENT, REOPENED_EVENT]")); + 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.as_deref(), Some("CUR2")); assert!(!page.complete); - assert_eq!(page.events.len(), 2); + 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"); @@ -89,6 +105,28 @@ async fn fetches_one_targeted_lifecycle_page_and_normalizes_tracked_events() { 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] @@ -132,3 +170,46 @@ async fn malformed_tracked_event_reports_issue_cursor_and_stage() { 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 }, + "nodes": [{ + "__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 1a0db7c..b870ba8 100644 --- a/crates/github/tests/sync_test.rs +++ b/crates/github/tests/sync_test.rs @@ -149,7 +149,7 @@ async fn snapshot_piggybacks_stable_history_metadata_on_the_issue_query() { assert_eq!( snapshot.history[0].milestone, Some(MilestoneRef { - id: "M_1".into(), + id: Some("M_1".into()), title: "M1".into(), }) ); diff --git a/crates/history/src/store.rs b/crates/history/src/store.rs index 442da5a..19d619f 100644 --- a/crates/history/src/store.rs +++ b/crates/history/src/store.rs @@ -117,7 +117,7 @@ impl HistoryStore { let (milestone_id, milestone_title) = issue .milestone .as_ref() - .map(|milestone| (Some(milestone.id.as_str()), Some(milestone.title.as_str()))) + .map(|milestone| (milestone.id.as_deref(), Some(milestone.title.as_str()))) .unwrap_or((None, None)); transaction.execute( "INSERT INTO issues ( diff --git a/crates/history/tests/store_test.rs b/crates/history/tests/store_test.rs index d40b8fd..71f39fe 100644 --- a/crates/history/tests/store_test.rs +++ b/crates/history/tests/store_test.rs @@ -16,7 +16,7 @@ fn issue( created_at, updated_at, milestone: milestone.map(|(id, title)| MilestoneRef { - id: id.into(), + id: Some(id.into()), title: title.into(), }), } @@ -51,7 +51,7 @@ fn initializes_creation_history_idempotently_and_reads_ordered_deltas() { &first_events[0].kind, HistoryEventKind::IssueCreated { milestone: Some(milestone) - } if milestone.id == "M_v1" && milestone.title == "v1" + } 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); @@ -130,6 +130,17 @@ fn checkpoints_lifecycle_pages_atomically_and_resumes_without_duplicates() { 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()), complete: false, @@ -171,7 +182,17 @@ fn checkpoints_lifecycle_pages_atomically_and_resumes_without_duplicates() { .iter() .map(|event| event.provider_event_id.as_str()) .collect::>(), - vec!["I_1:issue_created", "E_close", "E_reopen"] + 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(), + }), + } ); } diff --git a/web/src/lib/StarMap.test.ts b/web/src/lib/StarMap.test.ts index 8b17a27..51c55b7 100644 --- a/web/src/lib/StarMap.test.ts +++ b/web/src/lib/StarMap.test.ts @@ -102,6 +102,36 @@ describe('StarMap wrapper', () => { ) }) + it('updates milestone overlay state without routing a new selection', () => { + const setModel = vi.spyOn(Renderer.prototype, 'setModel') + const select = vi.spyOn(Renderer.prototype, 'select') + const target = document.createElement('div') + document.body.appendChild(target) + const initial = space(42) + initial.stars[0].milestone = 'Alpha' + + const component = mount(StarMapTestHost, { + target, + props: { initialSpace: initial, initialSelectedIssue: 42 }, + }) + mounted.push(component) + flushSync() + + select.mockClear() + component.updateSpace({ + ...initial, + stars: [{ ...initial.stars[0], milestone: 'Beta ' }], + }) + 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') diff --git a/web/src/lib/history.test.ts b/web/src/lib/history.test.ts index 69ba2a2..dd0b3d7 100644 --- a/web/src/lib/history.test.ts +++ b/web/src/lib/history.test.ts @@ -158,3 +158,79 @@ describe('temporal lifecycle projection', () => { 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') + }) +}) diff --git a/web/src/lib/history.ts b/web/src/lib/history.ts index 943e822..214fddb 100644 --- a/web/src/lib/history.ts +++ b/web/src/lib/history.ts @@ -1,7 +1,7 @@ import type { SpaceModel, Star, Status } from './model' export interface MilestoneRef { - id: string + id: string | null title: string } @@ -54,6 +54,7 @@ export function projectTemporalSpace( const creationByIssue = new Map>() const statusByIssue = new Map() + const milestoneByIssue = new Map() const ordered = events .filter((event) => event.occurred_at <= playhead) .toSorted( @@ -65,10 +66,13 @@ export function projectTemporalSpace( 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) } } @@ -80,7 +84,7 @@ export function projectTemporalSpace( ...star, live_status: star.status, status: statusByIssue.get(star.number) ?? 'open', - milestone: creation?.milestone?.title ?? null, + milestone: milestoneByIssue.get(star.number)?.title ?? null, temporal_visible: creation !== undefined, } }), diff --git a/web/src/lib/starmap/adapt.test.ts b/web/src/lib/starmap/adapt.test.ts index 54b2eb2..53d47bb 100644 --- a/web/src/lib/starmap/adapt.test.ts +++ b/web/src/lib/starmap/adapt.test.ts @@ -67,4 +67,13 @@ describe('toRendererModel', () => { false, ]) }) + + 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 ') + }) }) diff --git a/web/src/lib/starmap/adapt.ts b/web/src/lib/starmap/adapt.ts index c8e30f1..306280d 100644 --- a/web/src/lib/starmap/adapt.ts +++ b/web/src/lib/starmap/adapt.ts @@ -6,19 +6,20 @@ export function toRendererModel(space: SpaceModel | TemporalSpace): Ticket[] { return space.stars.map((star) => { const liveStatus = 'live_status' in star ? star.live_status : star.status return { - num: star.number, - slug: String(star.number), - title: star.title, - type: 'issue', - status: star.status, - blockedBy: [...star.blocked_by], - parentIssue: star.parent_issue, - frontier: star.status === 'frontier', - readyForAgent: - liveStatus === 'frontier' && - star.labels.some((label) => label.toLowerCase() === 'ready-for-agent'), - visible: 'temporal_visible' in star ? star.temporal_visible : true, - focusStatus: liveStatus, - } + num: star.number, + slug: String(star.number), + title: star.title, + type: 'issue', + status: star.status, + blockedBy: [...star.blocked_by], + parentIssue: star.parent_issue, + frontier: star.status === 'frontier', + readyForAgent: + liveStatus === 'frontier' && + star.labels.some((label) => label.toLowerCase() === 'ready-for-agent'), + visible: 'temporal_visible' in star ? star.temporal_visible : true, + focusStatus: liveStatus, + milestone: star.milestone, + } }) } diff --git a/web/src/lib/starmap/model.ts b/web/src/lib/starmap/model.ts index a0d33df..894e2d3 100644 --- a/web/src/lib/starmap/model.ts +++ b/web/src/lib/starmap/model.ts @@ -20,6 +20,7 @@ export interface Ticket { readyForAgent?: boolean visible?: boolean focusStatus?: TicketStatus + milestone?: string | null } export interface Map { diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index a4c59ca..8a680a6 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -429,6 +429,31 @@ describe('temporal visibility on the renderer seam', () => { 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) + }) }) // One frame against a recording stub context. The canvas *feel* can only be @@ -542,6 +567,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) { diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index 4d9bb55..93993c0 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -54,6 +54,7 @@ interface Node { type: string parentIssue: number | null visible: boolean + milestone: string | null vstate: VisualState // 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. @@ -231,6 +232,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 ?? '', @@ -354,6 +364,7 @@ export class StarMap { n.title = t.title n.type = t.type n.visible = t.visible !== false + n.milestone = t.milestone ?? null const vstate = visualState(t) const sstate = sessions[t.num] ?? null if (vstate !== n.vstate || sstate !== n.sstate) { @@ -388,6 +399,7 @@ export class StarMap { type: t.type, parentIssue: t.parentIssue, visible: t.visible !== false, + milestone: t.milestone ?? null, vstate: visualState(t), sstate: sessions[t.num] ?? null, x: p.x, @@ -541,6 +553,18 @@ export class StarMap { return out } + // 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 @@ -904,6 +928,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 e of this.#edges) { g.save() if (focused && !this.#focus.pathEdges.has(edgeKey(e.from, e.to))) { @@ -924,6 +949,48 @@ export class StarMap { this.#drawTicker(g) } + #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): void { const a = this.#byNum.get(e.from), b = this.#byNum.get(e.to) From 4d20b67b2479208bfc92169e32185d301baf6b11 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:17:36 -0500 Subject: [PATCH 06/18] feat(history): play complete history with event ticks Closes #82 Refs #81 --- CHANGELOG.md | 3 + web/src/App.svelte | 4 + web/src/App.test.ts | 17 +- web/src/lib/StarMap.svelte | 11 +- web/src/lib/StarMap.test.ts | 28 +++ web/src/lib/TemporalTimeline.svelte | 288 +++++++++++++++++++++++++-- web/src/lib/TemporalTimeline.test.ts | 124 +++++++++++- web/src/lib/playback.test.ts | 105 ++++++++++ web/src/lib/playback.ts | 143 +++++++++++++ web/src/lib/starmap/starmap.test.ts | 65 ++++++ web/src/lib/starmap/starmap.ts | 35 ++++ 11 files changed, 798 insertions(+), 25 deletions(-) create mode 100644 web/src/lib/playback.test.ts create mode 100644 web/src/lib/playback.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8dfc3..2387314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- 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. diff --git a/web/src/App.svelte b/web/src/App.svelte index 7949824..57e4dc9 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -76,6 +76,7 @@ const activeStar = $derived(resolvedRoute.star) let historyEvents = $state.raw([]) let historyPlayhead = $state(null) + let historyFeedback = $state.raw([]) let historySpaceId = $state(null) let requestedHistoryKey: string | null = null const temporalSpace = $derived( @@ -102,6 +103,7 @@ historySpaceId = space?.id ?? null historyEvents = [] historyPlayhead = null + historyFeedback = [] requestedHistoryKey = null } const summary = space?.history @@ -385,6 +387,7 @@ selectedIssue={activeStar?.number ?? null} select={(issueNumber) => route.go(activeSpace.id, issueNumber)} bottomInset={activeSpace.history === undefined ? 16 : 88} + replayEvents={historyFeedback} /> {#if activeSpace.history !== undefined} (historyPlayhead = playhead)} + reached={(events) => (historyFeedback = events)} /> {/if} {:else} diff --git a/web/src/App.test.ts b/web/src/App.test.ts index 9f43d77..e8a57e8 100644 --- a/web/src/App.test.ts +++ b/web/src/App.test.ts @@ -142,7 +142,10 @@ async function settle(): Promise { } describe('App issue routing', () => { - it('loads complete history once and scrubs renderer visibility without another request', async () => { + 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({ @@ -175,6 +178,7 @@ describe('App issue routing', () => { ) 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: [ @@ -211,6 +215,17 @@ describe('App issue routing', () => { {}, 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('distinguishes runtime loading from an authoritative empty model', () => { diff --git a/web/src/lib/StarMap.svelte b/web/src/lib/StarMap.svelte index 95a623a..9a8596d 100644 --- a/web/src/lib/StarMap.svelte +++ b/web/src/lib/StarMap.svelte @@ -1,6 +1,6 @@
@@ -49,20 +164,67 @@ {summary.diagnostic ?? 'History unavailable'} {/if} - {label} - {label} +
+ +
+ {#each ticks as tick, index} + + {/each} +
+ {#if tooltip !== null} + + {/if} +
+ - {/if} + aria-label={playing ? 'Pause issue history' : 'Play issue history'} + onclick={togglePlayback} + >{playing ? 'Pause' : 'Play'} +
diff --git a/web/src/lib/TemporalTimeline.test.ts b/web/src/lib/TemporalTimeline.test.ts index 7c0939a..61d60eb 100644 --- a/web/src/lib/TemporalTimeline.test.ts +++ b/web/src/lib/TemporalTimeline.test.ts @@ -9,6 +9,7 @@ const mounted: object[] = [] afterEach(async () => { for (const component of mounted.splice(0)) await unmount(component) document.body.innerHTML = '' + vi.restoreAllMocks() }) const complete: HistorySummary = { @@ -33,6 +34,44 @@ const events: HistoryEvent[] = [ 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) { @@ -67,7 +106,7 @@ describe('TemporalTimeline creation scrubber', () => { expect(target.querySelector('input[type="range"]')).toHaveProperty('disabled', true) }) - it('defaults to Now and emits local scrub and return-to-now changes', () => { + 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"]')! @@ -79,9 +118,88 @@ describe('TemporalTimeline creation scrubber', () => { 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×') + }) - const pastTarget = render({ summary: complete, events, playhead: 100, change }) - pastTarget.querySelector('button')!.click() + 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') + }) }) 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/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index 8a680a6..f270fd7 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. @@ -454,6 +455,70 @@ describe('temporal visibility on the renderer seam', () => { 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 diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index 93993c0..b93a08e 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -29,6 +29,7 @@ import { type WorkflowVisualState, } from './workflow-visual' import type { Ticket } from './model' +import type { HistoryEvent } from '../history' export type SelectHandler = (num: number | null) => void @@ -553,6 +554,31 @@ 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(historyCaption) + 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 { @@ -1481,6 +1507,15 @@ export class StarMap { } } +function historyCaption(event: HistoryEvent): string { + const issue = `#${event.issue_number.toString().padStart(2, '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}` +} + function now(): number { return (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000 } From 5bf14ffb868e96183c570c311c1697251ea3e7c2 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:46:11 -0500 Subject: [PATCH 07/18] feat(history): follow activity with delta-only synchronization Closes #83 Refs #82 --- CHANGELOG.md | 3 + crates/core/src/history.rs | 1 + crates/github/src/history.rs | 7 +- crates/github/tests/history_test.rs | 34 +++ crates/history/src/store.rs | 224 ++++++++++++--- crates/history/tests/store_test.rs | 110 +++++++- crates/server/src/poll.rs | 76 ++++- crates/server/src/routes.rs | 27 +- crates/server/tests/api_test.rs | 400 ++++++++++++++++++++++++++- web/src/App.svelte | 29 +- web/src/App.test.ts | 96 +++++++ web/src/lib/TemporalTimeline.svelte | 23 +- web/src/lib/TemporalTimeline.test.ts | 26 ++ web/src/lib/history.test.ts | 43 ++- web/src/lib/history.ts | 25 ++ 15 files changed, 1039 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2387314..09e45c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- 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. diff --git a/crates/core/src/history.rs b/crates/core/src/history.rs index bc5ff4f..e85beb8 100644 --- a/crates/core/src/history.rs +++ b/crates/core/src/history.rs @@ -98,5 +98,6 @@ pub struct HistoryPageRequest { pub struct HistoryPage { pub events: Vec, pub next_cursor: Option, + pub resume_cursor: Option, pub complete: bool, } diff --git a/crates/github/src/history.rs b/crates/github/src/history.rs index 14bfade..dbc33df 100644 --- a/crates/github/src/history.rs +++ b/crates/github/src/history.rs @@ -128,8 +128,12 @@ pub(crate) async fn fetch_history_page( events.sort(); let page_info = issue.timeline_items.page_info; + let resume_cursor = page_info + .end_cursor + .clone() + .or_else(|| request.cursor.clone()); let next_cursor = if page_info.has_next_page { - Some(page_info.end_cursor.ok_or_else(|| { + Some(page_info.end_cursor.clone().ok_or_else(|| { contextual_message( request, "advancing history page", @@ -142,6 +146,7 @@ pub(crate) async fn fetch_history_page( Ok(HistoryPage { events, next_cursor, + resume_cursor, complete: !page_info.has_next_page, }) } diff --git a/crates/github/tests/history_test.rs b/crates/github/tests/history_test.rs index 83b247e..2f06eea 100644 --- a/crates/github/tests/history_test.rs +++ b/crates/github/tests/history_test.rs @@ -96,6 +96,7 @@ async fn fetches_one_targeted_history_page_and_normalizes_tracked_events() { assert_eq!(body["variables"]["number"], 78); assert_eq!(body["variables"]["cursor"], "CUR1"); assert_eq!(page.next_cursor.as_deref(), Some("CUR2")); + assert_eq!(page.resume_cursor.as_deref(), Some("CUR2")); assert!(!page.complete); assert_eq!(page.events.len(), 4); assert_eq!(page.events[0].provider_event_id, "E_close"); @@ -129,6 +130,39 @@ async fn fetches_one_targeted_history_page_and_normalizes_tracked_events() { ); } +#[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" }, + "nodes": [] + } + } + } + } + }))) + .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; diff --git a/crates/history/src/store.rs b/crates/history/src/store.rs index 19d619f..172cbaf 100644 --- a/crates/history/src/store.rs +++ b/crates/history/src/store.rs @@ -8,7 +8,7 @@ use stellr_core::{ HistoryEvent, HistoryEventKind, HistoryImportState, HistorySummary, IssueSyncMetadata, }; -const SCHEMA_VERSION: i64 = 2; +const SCHEMA_VERSION: i64 = 3; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepositorySeed { @@ -33,6 +33,7 @@ pub struct PageCheckpoint { pub issue_id: String, pub events: Vec, pub next_cursor: Option, + pub resume_cursor: Option, pub complete: bool, } @@ -73,32 +74,28 @@ impl HistoryStore { (left.created_at, HistoryEvent::creation_id(&left.issue_id)) .cmp(&(right.created_at, HistoryEvent::creation_id(&right.issue_id))) }); - let total_issues = i64::try_from(issues.len())?; - let timeline_required = seed.timeline_required && total_issues > 0; - let import_state = if timeline_required { - "building" - } else { - "complete" - }; - let completed_issues = if timeline_required { 0 } else { total_issues }; - let verified_through = (!timeline_required).then_some(seed.verified_through); + 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 - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, ?7) + 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, - import_state, - total_issues, - completed_issues, - verified_through, - seed.verified_through + if timeline_required { + "building" + } else { + "complete" + }, + (!timeline_required).then_some(seed.verified_through), + seed.verified_through, + timeline_required, ], )?; let stored_repository_id: String = transaction.query_row( @@ -112,6 +109,31 @@ impl HistoryStore { )); } + 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)?; let (milestone_id, milestone_title) = issue @@ -119,28 +141,59 @@ impl HistoryStore { .as_ref() .map(|milestone| (milestone.id.as_deref(), Some(milestone.title.as_str()))) .unwrap_or((None, None)); - 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) - ON CONFLICT(space_id, provider_issue_id) DO UPDATE SET - issue_number = excluded.issue_number, - created_at = excluded.created_at, - updated_at = excluded.updated_at, - milestone_id = excluded.milestone_id, - milestone_title = excluded.milestone_title", - params![ - seed.space_id, - issue.issue_id, - issue_number, - issue.created_at, - issue.updated_at, - milestone_id, - milestone_title, - !timeline_required - ], - )?; + 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, + issue.updated_at, + milestone_id, + milestone_title, + !timeline_required + ], + )?; + } + Some(stored_updated_at) => { + let issue_changed = timeline_required && issue.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, + issue.updated_at, + milestone_id, + milestone_title, + issue_changed + ], + )?; + } + } let kind = HistoryEventKind::IssueCreated { milestone: issue.milestone, @@ -161,6 +214,39 @@ impl HistoryStore { )?; } + 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 { "complete" } else { "building" }, + total, + completed, + complete + ], + )?; + transaction.commit()?; drop(connection); self.summary(&seed.space_id)?.ok_or_else(|| { @@ -326,6 +412,11 @@ impl HistoryStore { )?; } + 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 @@ -333,22 +424,25 @@ impl HistoryStore { params![ checkpoint.space_id, checkpoint.issue_id, - checkpoint.next_cursor, + stored_cursor, checkpoint.complete ], )?; - let (completed, total): (i64, i64) = transaction.query_row( - "SELECT SUM(CASE WHEN complete = 1 THEN 1 ELSE 0 END), COUNT(*) - FROM issues WHERE space_id = ?1", + 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| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; - let repository_complete = completed == total; + let repository_complete = completed == total && !catch_up_required; transaction.execute( "UPDATE repositories SET import_state = ?2, completed_issues = ?3, - verified_through = ?4, + verified_through = CASE WHEN ?4 IS NOT NULL THEN ?4 ELSE verified_through END, diagnostic = NULL, resume_at = NULL WHERE space_id = ?1", @@ -393,6 +487,31 @@ impl HistoryStore { }) } + 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 = 'rate_limited', + diagnostic = 'GitHub rate limit exceeded', + resume_at = ?2 + WHERE space_id = ?1", + params![space_id, 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 remove_repository(&self, space_id: &str) -> Result { let connection = self.connection()?; Ok(connection.execute( @@ -486,6 +605,19 @@ fn migrate(connection: &mut Connection) -> Result<(), StoreError> { 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()?; } diff --git a/crates/history/tests/store_test.rs b/crates/history/tests/store_test.rs index 71f39fe..d7d024b 100644 --- a/crates/history/tests/store_test.rs +++ b/crates/history/tests/store_test.rs @@ -143,6 +143,7 @@ fn checkpoints_lifecycle_pages_atomically_and_resumes_without_duplicates() { ), ], next_cursor: Some("CUR2".into()), + resume_cursor: Some("CUR2".into()), complete: false, }; @@ -162,17 +163,20 @@ fn checkpoints_lifecycle_pages_atomically_and_resumes_without_duplicates() { Some("CUR2") ); - let complete = store + 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)); @@ -196,6 +200,110 @@ fn checkpoints_lifecycle_pages_atomically_and_resumes_without_duplicates() { ); } +#[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 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" + ); +} + #[test] fn schema_one_creation_ledgers_reopen_for_lifecycle_backfill() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/server/src/poll.rs b/crates/server/src/poll.rs index 4c1fca8..5577bef 100644 --- a/crates/server/src/poll.rs +++ b/crates/server/src/poll.rs @@ -2,7 +2,8 @@ use std::{sync::Arc, time::Duration}; use chrono::Utc; use stellr_core::{ - HistoryImportState, HistoryPageRequest, HistorySummary, Model, Provider, SpaceModel, derive, + HistoryImportState, HistoryPageRequest, HistorySummary, Model, Provider, ProviderError, + SpaceModel, derive, }; use stellr_github::cache::{Cache, Snapshot}; use stellr_history::RepositorySeed; @@ -106,14 +107,46 @@ async fn sync_spaces(state: &AppState, provider: &(dyn Provider + Send + Sync), 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 space = sync_space(entry, provider, cache, &state.history).await; + publish_space(state, space); + import_pending_history(state, provider, entry).await; + } } } +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_none_or(|resume_at| resume_at > Utc::now().timestamp()) + }) + { + return; + } loop { let pending = match state.history.pending_issue(&entry.id) { Ok(Some(pending)) => pending, @@ -132,10 +165,16 @@ async fn import_pending_history( let page = match provider.fetch_history_page(&entry.repo, &request).await { Ok(page) => page, Err(error) => { - let summary = state - .history - .mark_failed(&entry.id, error.to_string()) - .unwrap_or_else(|store_error| failed_history(store_error.to_string())); + let summary = match error { + ProviderError::RateLimited { reset_epoch } => state + .history + .mark_rate_limited(&entry.id, 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; } @@ -147,6 +186,7 @@ async fn import_pending_history( issue_id: pending.issue_id, events: page.events, next_cursor: page.next_cursor, + resume_cursor: page.resume_cursor, complete: page.complete, }) { Ok(summary) => summary, @@ -164,6 +204,20 @@ async fn import_pending_history( } } +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 { @@ -205,10 +259,20 @@ async fn sync_space( let (issues, synced_at) = snapshot .map(|snapshot| (snapshot.issues, Some(snapshot.synced_at))) .unwrap_or_default(); - let history = history_store + 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, *reset_epoch) + .unwrap_or(stored_history) + } + _ => stored_history, + }; model( entry, issues, diff --git a/crates/server/src/routes.rs b/crates/server/src/routes.rs index 4c5d883..971dddd 100644 --- a/crates/server/src/routes.rs +++ b/crates/server/src/routes.rs @@ -75,20 +75,21 @@ async fn history( .into_response(); } }; - let events = if summary.state == HistoryImportState::Complete { - 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(); + 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() - }; + } else { + Vec::new() + }; Json(HistoryResponse { summary, events }).into_response() } diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index 050c680..0572c26 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, @@ -14,7 +14,7 @@ use stellr_core::{ RepoRef, SpaceModel, }; use stellr_github::cache::{Cache, Snapshot}; -use stellr_history::{HistoryStore, RepositorySeed}; +use stellr_history::{HistoryStore, PageCheckpoint, RepositorySeed}; use stellr_server::{ poll::spawn_poller, spaces::{SpaceEntry, SpaceStore}, @@ -155,6 +155,96 @@ 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, ProviderError> { + unreachable!("the poller should consume the richer provider snapshot") + } + + async fn fetch_snapshot(&self, _repo: &RepoRef) -> Result { + let snapshot = self.snapshots.fetch_add(1, Ordering::SeqCst); + let updated_at = if snapshot >= 3 { 500 } else { 400 }; + Ok(ProviderSnapshot { + repository_id: Some("R_repo".into()), + issues: 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(), + }], + history: 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")); @@ -244,6 +334,96 @@ async fn history_endpoint_is_authenticated_scoped_and_sequence_incremental() { 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] @@ -280,11 +460,12 @@ impl Provider for HistorySnapshotProvider { async fn fetch_history_page( &self, _repo: &RepoRef, - _request: &HistoryPageRequest, + request: &HistoryPageRequest, ) -> Result { Ok(HistoryPage { events: vec![], next_cursor: None, + resume_cursor: request.cursor.clone(), complete: true, }) } @@ -292,6 +473,50 @@ impl Provider for HistorySnapshotProvider { struct LifecycleSnapshotProvider(AtomicUsize); +struct RateLimitedSnapshotProvider; + +#[async_trait::async_trait] +impl Provider for RateLimitedSnapshotProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + unreachable!("the poller should consume the richer provider snapshot") + } + + async fn fetch_snapshot(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot { + repository_id: Some("R_repo".into()), + issues: 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(), + }], + history: 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, ProviderError> { @@ -354,6 +579,7 @@ impl Provider for LifecycleSnapshotProvider { }, ], next_cursor: None, + resume_cursor: Some("CUR_END".into()), complete: true, }) } @@ -388,12 +614,20 @@ async fn successful_current_sync_seeds_creation_history_and_publishes_its_summar Cache::new(directory.path().join("cache")), Duration::from_secs(60), ); - tokio::time::timeout(Duration::from_secs(2), receiver.changed()) - .await - .expect("the startup poll should publish") - .expect("the model hub should stay open"); - - let model = receiver.borrow().clone(); + 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); @@ -432,7 +666,7 @@ async fn lifecycle_import_publishes_building_then_complete_without_blocking_curr Cache::new(directory.path().join("cache")), Duration::from_secs(60), ); - tokio::time::timeout(Duration::from_secs(2), async { + tokio::time::timeout(Duration::from_secs(5), async { loop { receiver.changed().await.unwrap(); let model = receiver.borrow().clone(); @@ -464,6 +698,152 @@ async fn lifecycle_import_publishes_building_then_complete_without_blocking_curr 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] diff --git a/web/src/App.svelte b/web/src/App.svelte index 57e4dc9..97ca3ba 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -10,7 +10,12 @@ import { Control, pageIssue, takePageToken } from './lib/control.svelte' import type { Model } from './lib/model' import { fetchHistory } from './lib/history-api' - import { projectTemporalSpace, type HistoryEvent } from './lib/history' + 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 +82,7 @@ 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( @@ -104,23 +110,34 @@ historyEvents = [] historyPlayhead = null historyFeedback = [] + historyHasNewActivity = false requestedHistoryKey = null } const summary = space?.history - if (space === null || summary?.state !== 'complete') return + if (space === null || summary?.verified_through === null || summary?.verified_through === undefined) return const key = `${space.id}:${summary.revision}` if (requestedHistoryKey === key) return requestedHistoryKey = key - void fetchHistory(space.id) + const after = latestHistorySequence(historyEvents) + void fetchHistory(space.id, after) .then((response) => { if (historySpaceId !== space.id) return - historyEvents = response.events + 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 @@ -394,8 +411,10 @@ summary={activeSpace.history} events={historyEvents} playhead={historyPlayhead} - change={(playhead) => (historyPlayhead = playhead)} + change={setHistoryPlayhead} reached={(events) => (historyFeedback = events)} + newActivity={historyHasNewActivity} + returnToNow={() => setHistoryPlayhead(null)} /> {/if} {:else} diff --git a/web/src/App.test.ts b/web/src/App.test.ts index e8a57e8..6b34847 100644 --- a/web/src/App.test.ts +++ b/web/src/App.test.ts @@ -228,6 +228,102 @@ describe('App issue routing', () => { 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 { 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(fetchRequest).toHaveBeenCalledTimes(2) + }) + it('distinguishes runtime loading from an authoritative empty model', () => { const { target, socket } = mountApp() diff --git a/web/src/lib/TemporalTimeline.svelte b/web/src/lib/TemporalTimeline.svelte index 9048ec6..a65b719 100644 --- a/web/src/lib/TemporalTimeline.svelte +++ b/web/src/lib/TemporalTimeline.svelte @@ -18,12 +18,16 @@ playhead = null, change = () => {}, reached = () => {}, + newActivity = false, + returnToNow = () => {}, }: { summary: HistorySummary events: HistoryEvent[] playhead?: number | null change?: (playhead: number | null) => void reached?: (events: HistoryEvent[]) => void + newActivity?: boolean + returnToNow?: () => void } = $props() let speed = $state(1) @@ -35,8 +39,7 @@ let frameHandle = 0 const hasHistory = $derived( - summary.state === 'complete' && - summary.earliest_event_at !== null && + summary.earliest_event_at !== null && summary.verified_through !== null && events.length > 0, ) @@ -164,6 +167,10 @@ {summary.diagnostic ?? 'History unavailable'} {/if} + {#if newActivity && playhead !== null} + + {/if} + {label}
{ 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('Building history · 1/2 issues') + }) + it('reports an empty complete ledger without enabling a slider', () => { const target = render({ summary: { ...complete, earliest_event_at: null, revision: 0 }, @@ -202,4 +213,19 @@ describe('TemporalTimeline creation scrubber', () => { 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/history.test.ts b/web/src/lib/history.test.ts index dd0b3d7..335aa0b 100644 --- a/web/src/lib/history.test.ts +++ b/web/src/lib/history.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { projectTemporalSpace, type HistoryEvent } from './history' +import { + latestHistorySequence, + mergeHistoryEvents, + projectTemporalSpace, + type HistoryEvent, +} from './history' import type { SpaceModel } from './model' const space: SpaceModel = { @@ -234,3 +239,39 @@ describe('temporal milestone projection', () => { 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 }, + { + 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', + }, + ] + + 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(3) + expect(mergeHistoryEvents(merged, delta)).toEqual(merged) + }) +}) diff --git a/web/src/lib/history.ts b/web/src/lib/history.ts index 214fddb..c4f4d2a 100644 --- a/web/src/lib/history.ts +++ b/web/src/lib/history.ts @@ -36,6 +36,31 @@ export interface TemporalSpace extends Omit { stars: TemporalStar[] } +export function mergeHistoryEvents( + current: HistoryEvent[], + incoming: HistoryEvent[], +): HistoryEvent[] { + const sequences = new Set() + const providerEvents = new Set() + const merged: HistoryEvent[] = [] + for (const event of [...current, ...incoming]) { + const providerKey = `${event.repository_id}\u0000${event.provider_event_id}` + if (sequences.has(event.sequence) || providerEvents.has(providerKey)) continue + sequences.add(event.sequence) + providerEvents.add(providerKey) + merged.push(event) + } + return merged.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[], From fbe1a3d5954e2be1c4204b6cfb00623852356f68 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:11:22 -0500 Subject: [PATCH 08/18] feat(history): harden native temporal playback Closes #84 Refs #83 --- CHANGELOG.md | 4 + crates/app/tests/runtime_test.rs | 165 ++++++++++++++++- crates/core/src/provider.rs | 3 + crates/core/tests/model_contract_test.rs | 1 + crates/github/src/history.rs | 72 +++++--- crates/github/src/sync.rs | 77 ++++++-- crates/github/tests/history_test.rs | 58 +++--- crates/github/tests/sync_test.rs | 90 ++++++---- crates/history/src/store.rs | 215 ++++++++++++++++++----- crates/history/tests/store_test.rs | 174 ++++++++++++++++++ crates/server/src/poll.rs | 41 ++++- crates/server/src/routes.rs | 10 ++ crates/server/tests/api_test.rs | 50 +++++- web/src/App.test.ts | 11 +- web/src/lib/StarMap.svelte | 16 ++ web/src/lib/StarMap.test.ts | 39 +++- web/src/lib/TemporalTimeline.svelte | 53 ++++-- web/src/lib/TemporalTimeline.test.ts | 61 ++++++- web/src/lib/history.test.ts | 42 +++-- web/src/lib/history.ts | 25 ++- web/src/lib/starmap/adapt.test.ts | 27 +++ web/src/lib/starmap/adapt.ts | 7 +- web/src/lib/starmap/model.ts | 1 + web/src/lib/starmap/starmap.test.ts | 28 +++ web/src/lib/starmap/starmap.ts | 38 ++-- 25 files changed, 1105 insertions(+), 203 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e45c7..3bd41c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- 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. diff --git a/crates/app/tests/runtime_test.rs b/crates/app/tests/runtime_test.rs index 7751289..5c7fcb7 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, RawIssue, 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,72 @@ 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, ProviderError> { + Ok(vec![historical_issue()]) + } + + async fn fetch_snapshot(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot { + repository_id: Some("R_stellr".into()), + history_cutoff: Some(1_753_000_000), + issues: vec![historical_issue()], + history: 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, ProviderError> { + 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 +188,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/core/src/provider.rs b/crates/core/src/provider.rs index 7bab911..43249cd 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -19,6 +19,7 @@ pub trait Provider { async fn fetch_snapshot(&self, repo: &RepoRef) -> Result { Ok(ProviderSnapshot { repository_id: None, + history_cutoff: None, issues: self.fetch(repo).await?, history: Vec::new(), }) @@ -38,6 +39,8 @@ pub trait Provider { #[derive(Debug, Clone, PartialEq)] pub struct ProviderSnapshot { pub repository_id: Option, + /// Provider-backed boundary through which history can be claimed complete. + pub history_cutoff: Option, pub issues: Vec, pub history: Vec, } diff --git a/crates/core/tests/model_contract_test.rs b/crates/core/tests/model_contract_test.rs index 3f68c48..fc02d6a 100644 --- a/crates/core/tests/model_contract_test.rs +++ b/crates/core/tests/model_contract_test.rs @@ -26,6 +26,7 @@ async fn current_only_providers_get_a_backward_compatible_snapshot() { snapshot, ProviderSnapshot { repository_id: None, + history_cutoff: None, issues: Vec::new(), history: Vec::new(), } diff --git a/crates/github/src/history.rs b/crates/github/src/history.rs index dbc33df..78f8f3e 100644 --- a/crates/github/src/history.rs +++ b/crates/github/src/history.rs @@ -27,12 +27,15 @@ query FetchIssueHistory( hasNextPage endCursor } - nodes { - __typename - ... on ClosedEvent { id createdAt } - ... on ReopenedEvent { id createdAt } - ... on DemilestonedEvent { id createdAt milestoneTitle } - ... on MilestonedEvent { id createdAt milestoneTitle } + edges { + cursor + node { + __typename + ... on ClosedEvent { id createdAt } + ... on ReopenedEvent { id createdAt } + ... on DemilestonedEvent { id createdAt milestoneTitle } + ... on MilestonedEvent { id createdAt milestoneTitle } + } } } } @@ -75,7 +78,10 @@ pub(crate) async fn fetch_history_page( } let mut events = Vec::new(); - for node in issue.timeline_items.nodes { + 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, @@ -87,7 +93,10 @@ pub(crate) async fn fetch_history_page( from: None, to: Some(historical_milestone(request, &node)?), }, - _ => continue, + _ => { + accepted_cursor = Some(edge.cursor); + continue; + } }; let provider_event_id = node.id.ok_or_else(|| { contextual_message( @@ -113,26 +122,33 @@ pub(crate) async fn fetch_history_page( contextual_parse(request, "normalizing lifecycle event", error) }) })?; - if occurred_at <= request.cutoff { - 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, - }); + 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 = page_info - .end_cursor - .clone() - .or_else(|| request.cursor.clone()); - let next_cursor = if page_info.has_next_page { + 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, @@ -147,7 +163,7 @@ pub(crate) async fn fetch_history_page( events, next_cursor, resume_cursor, - complete: !page_info.has_next_page, + complete: reached_cutoff || !page_info.has_next_page, }) } @@ -222,7 +238,13 @@ struct Issue { #[serde(rename_all = "camelCase")] struct TimelineConnection { page_info: PageInfo, - nodes: Vec, + edges: Vec, +} + +#[derive(Deserialize)] +struct TimelineEdge { + cursor: String, + node: TimelineNode, } #[derive(Deserialize)] diff --git a/crates/github/src/sync.rs b/crates/github/src/sync.rs index 4c3c0b3..27d5cb9 100644 --- a/crates/github/src/sync.rs +++ b/crates/github/src/sync.rs @@ -5,8 +5,7 @@ use octocrab::{FromResponse, Octocrab}; use serde::{Deserialize, Serialize}; use serde_json::Value; use stellr_core::{ - IssueState, IssueSyncMetadata, MilestoneRef, Provider, ProviderError, ProviderSnapshot, - RawIssue, RepoRef, + IssueState, IssueSyncMetadata, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef, }; use crate::textref; @@ -35,7 +34,7 @@ query FetchIssues($owner: String!, $name: String!, $cursor: String) { assignees(first: 10) { nodes { login } } - milestone { id title } + milestone { title } labels(first: 20) { nodes { name } } @@ -78,6 +77,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)) @@ -87,17 +93,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) @@ -121,10 +138,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, } @@ -167,6 +208,7 @@ impl GithubProvider { let mut cursor = None; let mut nodes = Vec::new(); let mut repository_id: Option = None; + let mut history_cutoff = None; loop { let request = GraphqlRequest { @@ -178,9 +220,12 @@ impl 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 repository = response @@ -212,7 +257,7 @@ impl GithubProvider { })?); } - Ok(map_snapshot(repository_id, nodes)) + Ok(map_snapshot(repository_id, history_cutoff, nodes)) } } @@ -223,7 +268,11 @@ fn map_octocrab_error(error: octocrab::Error) -> ProviderError { } } -fn map_snapshot(repository_id: Option, nodes: Vec) -> ProviderSnapshot { +fn map_snapshot( + 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(); @@ -247,10 +296,8 @@ fn map_snapshot(repository_id: Option, nodes: Vec) -> Provide number: node.number, created_at: node.created_at.timestamp(), updated_at: node.updated_at.timestamp(), - milestone: node.milestone.as_ref().map(|milestone| MilestoneRef { - id: Some(milestone.id.clone()), - title: milestone.title.clone(), - }), + // Present-day milestone membership cannot establish membership at creation. + milestone: None, }); issues.push(RawIssue { number: node.number, @@ -301,6 +348,7 @@ fn map_snapshot(repository_id: Option, nodes: Vec) -> Provide ProviderSnapshot { repository_id, + history_cutoff, issues, history, } @@ -387,7 +435,6 @@ struct Assignee { #[derive(Clone, Deserialize)] struct Milestone { - id: String, title: String, } diff --git a/crates/github/tests/history_test.rs b/crates/github/tests/history_test.rs index 2f06eea..0b8084f 100644 --- a/crates/github/tests/history_test.rs +++ b/crates/github/tests/history_test.rs @@ -35,39 +35,39 @@ async fn fetches_one_targeted_history_page_and_normalizes_tracked_events() { "id": "I_78", "timelineItems": { "pageInfo": { "hasNextPage": true, "endCursor": "CUR2" }, - "nodes": [ - { + "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" - } + }} ] } } @@ -95,9 +95,9 @@ async fn fetches_one_targeted_history_page_and_normalizes_tracked_events() { assert_eq!(body["variables"]["name"], "r"); assert_eq!(body["variables"]["number"], 78); assert_eq!(body["variables"]["cursor"], "CUR1"); - assert_eq!(page.next_cursor.as_deref(), Some("CUR2")); - assert_eq!(page.resume_cursor.as_deref(), Some("CUR2")); - assert!(!page.complete); + 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)); @@ -143,7 +143,7 @@ async fn preserves_the_terminal_cursor_for_delta_only_resume() { "id": "I_78", "timelineItems": { "pageInfo": { "hasNextPage": false, "endCursor": "CUR_END" }, - "nodes": [] + "edges": [] } } } @@ -176,10 +176,13 @@ async fn malformed_tracked_event_reports_issue_cursor_and_stage() { "id": "I_78", "timelineItems": { "pageInfo": { "hasNextPage": false, "endCursor": null }, - "nodes": [{ - "__typename": "ClosedEvent", - "id": null, - "createdAt": "2026-08-04T13:00:00Z" + "edges": [{ + "cursor": "EDGE1", + "node": { + "__typename": "ClosedEvent", + "id": null, + "createdAt": "2026-08-04T13:00:00Z" + } }] } } @@ -218,11 +221,14 @@ async fn malformed_milestone_event_reports_issue_cursor_and_stage() { "id": "I_78", "timelineItems": { "pageInfo": { "hasNextPage": false, "endCursor": null }, - "nodes": [{ - "__typename": "MilestonedEvent", - "id": "E_milestone", - "createdAt": "2026-08-04T13:00:00Z", - "milestoneTitle": null + "edges": [{ + "cursor": "EDGE1", + "node": { + "__typename": "MilestonedEvent", + "id": "E_milestone", + "createdAt": "2026-08-04T13:00:00Z", + "milestoneTitle": null + } }] } } diff --git a/crates/github/tests/sync_test.rs b/crates/github/tests/sync_test.rs index b870ba8..cbcdfa0 100644 --- a/crates/github/tests/sync_test.rs +++ b/crates/github/tests/sync_test.rs @@ -1,5 +1,5 @@ use serde_json::{Value, json}; -use stellr_core::{IssueState, MilestoneRef, Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{IssueState, Provider, ProviderError, RawIssue, RepoRef}; use stellr_github::sync::GithubProvider; use wiremock::matchers::{body_partial_json, body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -97,32 +97,36 @@ 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).set_body_json(json!({ - "data": { - "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 - }] + .respond_with( + ResponseTemplate::new(200) + .insert_header("date", "Tue, 04 Aug 2026 13:30:00 GMT") + .set_body_json(json!({ + "data": { + "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; @@ -140,19 +144,14 @@ async fn snapshot_piggybacks_stable_history_metadata_on_the_issue_query() { assert!(query.contains("updatedAt")); let snapshot = result.unwrap(); 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, - Some(MilestoneRef { - id: Some("M_1".into()), - title: "M1".into(), - }) - ); + assert_eq!(snapshot.history[0].milestone, None); } #[tokio::test] @@ -200,6 +199,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; diff --git a/crates/history/src/store.rs b/crates/history/src/store.rs index 172cbaf..28172a8 100644 --- a/crates/history/src/store.rs +++ b/crates/history/src/store.rs @@ -10,6 +10,56 @@ use stellr_core::{ 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, @@ -89,9 +139,9 @@ impl HistoryStore { seed.space_id, seed.provider_repository_id, if timeline_required { - "building" + StoredImportState::Building.as_str() } else { - "complete" + StoredImportState::Complete.as_str() }, (!timeline_required).then_some(seed.verified_through), seed.verified_through, @@ -136,6 +186,10 @@ impl HistoryStore { 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() @@ -162,7 +216,7 @@ impl HistoryStore { issue.issue_id, issue_number, issue.created_at, - issue.updated_at, + observed_updated_at, milestone_id, milestone_title, !timeline_required @@ -170,7 +224,8 @@ impl HistoryStore { )?; } Some(stored_updated_at) => { - let issue_changed = timeline_required && issue.updated_at > stored_updated_at; + let issue_changed = + timeline_required && observed_updated_at > stored_updated_at; changed |= issue_changed; transaction.execute( "UPDATE issues @@ -186,7 +241,7 @@ impl HistoryStore { issue.issue_id, issue_number, issue.created_at, - issue.updated_at, + observed_updated_at, milestone_id, milestone_title, issue_changed @@ -195,23 +250,25 @@ impl HistoryStore { } } - 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 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 { @@ -240,7 +297,11 @@ impl HistoryStore { WHERE space_id = ?1", params![ seed.space_id, - if complete { "complete" } else { "building" }, + if complete { + StoredImportState::Complete.as_str() + } else { + StoredImportState::Building.as_str() + }, total, completed, complete @@ -374,11 +435,11 @@ impl HistoryStore { params![checkpoint.space_id], |row| Ok((row.get(0)?, row.get(1)?)), )?; - let issue_number: i64 = transaction.query_row( - "SELECT issue_number FROM issues + 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| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), )?; let issue_number = u64::try_from(issue_number)?; @@ -411,6 +472,12 @@ impl HistoryStore { ], )?; } + reconcile_creation_milestone( + &transaction, + &checkpoint.space_id, + &checkpoint.issue_id, + issue_created_at, + )?; let stored_cursor = if checkpoint.complete { checkpoint.resume_cursor.as_ref() @@ -449,9 +516,9 @@ impl HistoryStore { params![ checkpoint.space_id, if repository_complete { - "complete" + StoredImportState::Complete.as_str() } else { - "building" + StoredImportState::Building.as_str() }, completed, repository_complete.then_some(cutoff) @@ -472,9 +539,13 @@ impl HistoryStore { let connection = self.connection()?; let changed = connection.execute( "UPDATE repositories - SET import_state = 'failed', diagnostic = ?2, resume_at = NULL + SET import_state = ?2, diagnostic = ?3, resume_at = NULL WHERE space_id = ?1", - params![space_id, diagnostic.into()], + params![ + space_id, + StoredImportState::Failed.as_str(), + diagnostic.into() + ], )?; if changed == 0 { return Err(StoreError::Invalid( @@ -495,11 +566,11 @@ impl HistoryStore { let connection = self.connection()?; let changed = connection.execute( "UPDATE repositories - SET import_state = 'rate_limited', + SET import_state = ?2, diagnostic = 'GitHub rate limit exceeded', - resume_at = ?2 + resume_at = ?3 WHERE space_id = ?1", - params![space_id, resume_at], + params![space_id, StoredImportState::RateLimited.as_str(), resume_at], )?; if changed == 0 { return Err(StoreError::Invalid( @@ -512,6 +583,23 @@ impl HistoryStore { }) } + 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( @@ -527,6 +615,55 @@ impl HistoryStore { } } +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( @@ -675,17 +812,7 @@ fn summary(connection: &Connection, space_id: &str) -> Result Result { - match state { - "unavailable" => Ok(HistoryImportState::Unavailable), - "building" => Ok(HistoryImportState::Building), - "complete" => Ok(HistoryImportState::Complete), - "delayed" => Ok(HistoryImportState::Delayed), - "rate_limited" => Ok(HistoryImportState::RateLimited), - "failed" => Ok(HistoryImportState::Failed), - _ => Err(StoreError::Invalid(format!( - "unknown history import state: {state}" - ))), - } + StoredImportState::parse(state).map(Into::into) } #[derive(Debug, thiserror::Error)] diff --git a/crates/history/tests/store_test.rs b/crates/history/tests/store_test.rs index d7d024b..e426274 100644 --- a/crates/history/tests/store_test.rs +++ b/crates/history/tests/store_test.rs @@ -200,6 +200,40 @@ fn checkpoints_lifecycle_pages_atomically_and_resumes_without_duplicates() { ); } +#[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(); @@ -272,6 +306,84 @@ fn completed_ledgers_verify_once_then_request_only_new_or_changed_issues() { 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(); @@ -302,6 +414,13 @@ fn rate_limit_evidence_preserves_pending_work_and_resume_time() { .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] @@ -373,3 +492,58 @@ fn schema_one_creation_ledgers_reopen_for_lifecycle_backfill() { 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/src/poll.rs b/crates/server/src/poll.rs index 5577bef..c606921 100644 --- a/crates/server/src/poll.rs +++ b/crates/server/src/poll.rs @@ -10,6 +10,8 @@ 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, @@ -142,7 +144,7 @@ async fn import_pending_history( summary.state == HistoryImportState::RateLimited && summary .resume_at - .is_none_or(|resume_at| resume_at > Utc::now().timestamp()) + .is_some_and(|resume_at| resume_at > Utc::now().timestamp()) }) { return; @@ -168,7 +170,7 @@ async fn import_pending_history( let summary = match error { ProviderError::RateLimited { reset_epoch } => state .history - .mark_rate_limited(&entry.id, reset_epoch) + .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 @@ -244,7 +246,7 @@ async fn sync_space( synced_at, }, ); - let history = history_after_snapshot(entry, &snapshot, history_store, synced_at); + let history = history_after_snapshot(entry, &snapshot, history_store); model( entry, snapshot.issues, @@ -268,7 +270,7 @@ async fn sync_space( if stored_history.state != HistoryImportState::Unavailable => { history_store - .mark_rate_limited(&entry.id, *reset_epoch) + .mark_rate_limited(&entry.id, Some(rate_limit_resume(*reset_epoch))) .unwrap_or(stored_history) } _ => stored_history, @@ -285,11 +287,18 @@ async fn sync_space( } } +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, - verified_through: i64, ) -> HistorySummary { let Some(repository_id) = snapshot.repository_id.as_ref() else { return store @@ -297,6 +306,12 @@ fn history_after_snapshot( .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", @@ -342,3 +357,19 @@ fn model( 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 971dddd..4afd381 100644 --- a/crates/server/src/routes.rs +++ b/crates/server/src/routes.rs @@ -113,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() } @@ -181,6 +188,9 @@ async fn refresh_space(State(state): State>, Path(id): Path= 3 { 500 } else { 400 }; Ok(ProviderSnapshot { repository_id: Some("R_repo".into()), + history_cutoff: Some(500), issues: vec![RawIssue { number: 83, parent_issue: None, @@ -435,6 +436,7 @@ impl Provider for HistorySnapshotProvider { async fn fetch_snapshot(&self, _repo: &RepoRef) -> Result { Ok(ProviderSnapshot { repository_id: Some("R_repo".into()), + history_cutoff: Some(500), issues: vec![RawIssue { number: 78, parent_issue: None, @@ -484,6 +486,7 @@ impl Provider for RateLimitedSnapshotProvider { async fn fetch_snapshot(&self, _repo: &RepoRef) -> Result { Ok(ProviderSnapshot { repository_id: Some("R_repo".into()), + history_cutoff: Some(500), issues: vec![RawIssue { number: 83, parent_issue: None, @@ -526,6 +529,7 @@ impl Provider for LifecycleSnapshotProvider { async fn fetch_snapshot(&self, _repo: &RepoRef) -> Result { Ok(ProviderSnapshot { repository_id: Some("R_repo".into()), + history_cutoff: Some(500), issues: vec![RawIssue { number: 79, parent_issue: None, @@ -990,12 +994,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: HistoryStore::open_in_memory().unwrap(), + history: history.clone(), }); let poller = spawn_poller( state.clone(), @@ -1032,6 +1052,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(); } @@ -1081,12 +1110,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: HistoryStore::open_in_memory().unwrap(), + history: history.clone(), }); let base = serve(state).await; let client = reqwest::Client::new(); @@ -1100,6 +1130,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")) @@ -1109,6 +1154,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] diff --git a/web/src/App.test.ts b/web/src/App.test.ts index 6b34847..51bafde 100644 --- a/web/src/App.test.ts +++ b/web/src/App.test.ts @@ -277,8 +277,9 @@ describe('App issue routing', () => { }), { 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: [ @@ -321,6 +322,14 @@ describe('App issue routing', () => { 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) }) diff --git a/web/src/lib/StarMap.svelte b/web/src/lib/StarMap.svelte index 9a8596d..3841206 100644 --- a/web/src/lib/StarMap.svelte +++ b/web/src/lib/StarMap.svelte @@ -74,6 +74,9 @@
+{#if 'temporal_active' in space && space.temporal_active} +
Current dependencies
+{/if} diff --git a/web/src/lib/StarMap.test.ts b/web/src/lib/StarMap.test.ts index 6fbacc7..86f6527 100644 --- a/web/src/lib/StarMap.test.ts +++ b/web/src/lib/StarMap.test.ts @@ -7,7 +7,7 @@ import { flushSync, mount, unmount } from 'svelte' import StarMap from './StarMap.svelte' import StarMapTestHost from './StarMap.test-host.svelte' import type { SpaceModel } from './model' -import type { HistoryEvent } from './history' +import { projectTemporalSpace, type HistoryEvent } from './history' import { StarMap as Renderer } from './starmap/starmap' const mounted: object[] = [] @@ -152,6 +152,43 @@ 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 }, + }) + mounted.push(component) + flushSync() + + expect(target.textContent).toContain('Current dependencies') + expect(setModel).toHaveBeenLastCalledWith( + [expect.objectContaining({ num: 42, historical: true, frontier: false })], + {}, + 14, + ) + }) + it('forwards reached history events with the reduced-motion preference', () => { const replayHistory = vi.spyOn(Renderer.prototype, 'replayHistory') vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) diff --git a/web/src/lib/TemporalTimeline.svelte b/web/src/lib/TemporalTimeline.svelte index a65b719..76684a3 100644 --- a/web/src/lib/TemporalTimeline.svelte +++ b/web/src/lib/TemporalTimeline.svelte @@ -1,6 +1,6 @@ ') }) + + 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 306280d..29c7180 100644 --- a/web/src/lib/starmap/adapt.ts +++ b/web/src/lib/starmap/adapt.ts @@ -3,6 +3,7 @@ import type { TemporalSpace } from '../history' import type { Ticket } from './model' export function toRendererModel(space: SpaceModel | TemporalSpace): Ticket[] { + const historical = 'temporal_active' in space && space.temporal_active return space.stars.map((star) => { const liveStatus = 'live_status' in star ? star.live_status : star.status return { @@ -13,13 +14,15 @@ export function toRendererModel(space: SpaceModel | TemporalSpace): Ticket[] { status: star.status, blockedBy: [...star.blocked_by], parentIssue: star.parent_issue, - frontier: star.status === 'frontier', + frontier: !historical && star.status === 'frontier', readyForAgent: + !historical && liveStatus === 'frontier' && star.labels.some((label) => label.toLowerCase() === 'ready-for-agent'), visible: 'temporal_visible' in star ? star.temporal_visible : true, - focusStatus: liveStatus, + focusStatus: historical ? star.status : liveStatus, milestone: star.milestone, + historical, } }) } diff --git a/web/src/lib/starmap/model.ts b/web/src/lib/starmap/model.ts index 894e2d3..e4afc4f 100644 --- a/web/src/lib/starmap/model.ts +++ b/web/src/lib/starmap/model.ts @@ -21,6 +21,7 @@ export interface Ticket { visible?: boolean focusStatus?: TicketStatus milestone?: string | null + historical?: boolean } export interface Map { diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index f270fd7..8afda28 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -404,6 +404,34 @@ 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) => ({ diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index b93a08e..3c73057 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -29,7 +29,7 @@ import { type WorkflowVisualState, } from './workflow-visual' import type { Ticket } from './model' -import type { HistoryEvent } from '../history' +import { historyEventSummary, type HistoryEvent } from '../history' export type SelectHandler = (num: number | null) => void @@ -47,6 +47,7 @@ interface RenderEdge extends WorkflowEdge { state: WorkflowVisualState satisfied: boolean reverseExists: boolean + historical: boolean } interface Node { @@ -292,6 +293,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. @@ -351,6 +353,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) @@ -377,7 +382,7 @@ export class StarMap { } if (changed.length) this.#tick(changed.join(' · ')) this.#refreshEdges(tickets) - if (focusChanged) { + if (focusChanged && !historicalTransition) { this.#refit(false) this.#settleIfHeadless() } @@ -417,6 +422,7 @@ 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) @@ -430,6 +436,7 @@ export class StarMap { state: workflowVisualState(edge, byNum), satisfied: this.#resolved.has(edge.from), reverseExists: reverseEdges.has(edgeKey(edge.from, edge.to)), + historical, })) } @@ -564,7 +571,7 @@ export class StarMap { if (node) node.flare = 1 } } - const summaries = events.map(historyCaption) + const summaries = events.map((event) => historyEventSummary(event, 2)) const caption = summaries.length <= 3 ? summaries.join(' · ') @@ -1065,7 +1072,11 @@ export class StarMap { g.quadraticCurveTo(cx, cy, bx, by) g.lineCap = 'round' const usesResolvedStyle = mini ? e.state !== 'incomplete' : e.satisfied - if (usesResolvedStyle) { + if (e.historical) { + g.strokeStyle = 'rgba(174,192,218,0.32)' + g.lineWidth = 1.5 + g.setLineDash([4, 8]) + } else if (usesResolvedStyle) { g.strokeStyle = 'rgba(190,225,200,0.82)' g.lineWidth = 3 g.setLineDash([]) @@ -1082,7 +1093,7 @@ export class StarMap { g.setLineDash([]) // A satisfied edge (blocker resolved) flows particles blocker→dependent, so // the frontier visibly ignites as paths clear (starmap-design.md dec. 5). - if (mini ? e.state === 'traversed' : e.satisfied) { + if (!e.historical && (mini ? e.state === 'traversed' : e.satisfied)) { for (let k = 0; k < 3; k++) { const u = mod(this.#clock * 0.1 + k / 3 + (e.from * 0.13 + e.to * 0.07), 1), m = 1 - u @@ -1117,7 +1128,13 @@ 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 ? '#d9f3df' : mini ? '#c7b8ff' : '#c8d5e8' + g.fillStyle = e.historical + ? 'rgba(174,192,218,0.5)' + : usesResolvedStyle + ? '#d9f3df' + : mini + ? '#c7b8ff' + : '#c8d5e8' g.fill() } @@ -1507,15 +1524,6 @@ export class StarMap { } } -function historyCaption(event: HistoryEvent): string { - const issue = `#${event.issue_number.toString().padStart(2, '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}` -} - function now(): number { return (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000 } From 80ea1c1678b05230fbf64341f8ac47f4e891327f Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:35:06 -0500 Subject: [PATCH 09/18] docs: design node visual hierarchy and depth --- ...08-06-node-visual-hierarchy-2-5d-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-node-visual-hierarchy-2-5d-design.md 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..0c13cb2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-node-visual-hierarchy-2-5d-design.md @@ -0,0 +1,201 @@ +# 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 is the selected issue in this +renderer and the selection ring already communicates that state. 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. Among unselected nodes, 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, 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. From b159837b1ba858cbf625ba96b3339cca43b24e9f Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:20:25 -0500 Subject: [PATCH 10/18] docs: distinguish current and selected nodes --- ...08-06-node-visual-hierarchy-2-5d-design.md | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) 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 index 0c13cb2..5a1b5f5 100644 --- 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 @@ -89,10 +89,12 @@ status ring and one selection ring around a node. 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 is the selected issue in this -renderer and the selection ring already communicates that state. Replace the -ring-shaped click flare with a brief increase in sphere glow so interaction -feedback cannot create a third outer ring. +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, @@ -113,11 +115,12 @@ existing issue-radius scale is applied: - out-of-scope: retain `4.5` and `18`. Selection remains the first label-placement priority because it reflects the -operator's immediate action. Among unselected nodes, 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. +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 @@ -180,8 +183,8 @@ Add or update tests that prove: - 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, then in-progress, ready, ordinary - frontier, and blocked; +- 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; From 0e612356f992e4192a9e79ef4344d8e928cb1c5c Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:22:10 -0500 Subject: [PATCH 11/18] docs: plan node visual hierarchy implementation --- .../2026-08-06-node-visual-hierarchy-2-5d.md | 518 ++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-node-visual-hierarchy-2-5d.md 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. From a411cdc4baa1f52886c0e1d1227557fa952ebb27 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:33:10 -0500 Subject: [PATCH 12/18] feat(web): derive workflow visual priority --- web/src/lib/starmap/adapt.test.ts | 26 ++++++++++++++++++ web/src/lib/starmap/adapt.ts | 29 +++++++++++--------- web/src/lib/starmap/model.ts | 3 +++ web/src/lib/starmap/work-priority.test.ts | 17 ++++++++++++ web/src/lib/starmap/work-priority.ts | 32 +++++++++++++++++++++++ 5 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 web/src/lib/starmap/work-priority.test.ts create mode 100644 web/src/lib/starmap/work-priority.ts diff --git a/web/src/lib/starmap/adapt.test.ts b/web/src/lib/starmap/adapt.test.ts index 54b2eb2..4108c0c 100644 --- a/web/src/lib/starmap/adapt.test.ts +++ b/web/src/lib/starmap/adapt.test.ts @@ -35,6 +35,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 +54,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,5 +85,12 @@ describe('toRendererModel', () => { false, false, ]) + expect(model.map((ticket) => ticket.workPriority)).toEqual([ + 'ready', + 'frontier', + 'in_progress', + 'terminal', + 'blocked', + ]) }) }) diff --git a/web/src/lib/starmap/adapt.ts b/web/src/lib/starmap/adapt.ts index f8b3c05..26ddbe1 100644 --- a/web/src/lib/starmap/adapt.ts +++ b/web/src/lib/starmap/adapt.ts @@ -1,18 +1,21 @@ import type { SpaceModel } from '../model' import type { Ticket } from './model' +import { deriveWorkPriority } from './work-priority' export function toRendererModel(space: SpaceModel): Ticket[] { - return space.stars.map((star) => ({ - num: star.number, - slug: String(star.number), - title: star.title, - type: 'issue', - status: star.status, - blockedBy: [...star.blocked_by], - parentIssue: star.parent_issue, - frontier: star.status === 'frontier', - readyForAgent: - star.status === 'frontier' && - star.labels.some((label) => label.toLowerCase() === 'ready-for-agent'), - })) + return space.stars.map((star) => { + const workPriority = deriveWorkPriority(star) + return { + num: star.number, + slug: String(star.number), + title: star.title, + type: 'issue', + status: star.status, + blockedBy: [...star.blocked_by], + parentIssue: star.parent_issue, + frontier: star.status === 'frontier', + readyForAgent: workPriority === 'ready', + workPriority, + } + }) } diff --git a/web/src/lib/starmap/model.ts b/web/src/lib/starmap/model.ts index dcbd1b1..def7cd5 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' @@ -18,6 +20,7 @@ export interface Ticket { parentIssue: number | null frontier: boolean readyForAgent?: boolean + workPriority?: WorkPriority } export interface Map { 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' +} From 7228b5dbebc82d74bce861ae6c0d2aa5182c8062 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:38:29 -0500 Subject: [PATCH 13/18] feat(web): prioritize active node visuals --- web/src/lib/starmap/starmap.test.ts | 31 +++++++++++++++++-- web/src/lib/starmap/starmap.ts | 46 +++++++++++++++++------------ web/src/lib/starmap/theme.test.ts | 39 ++++++++++++++++++++++++ web/src/lib/starmap/theme.ts | 14 +++++++++ 4 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 web/src/lib/starmap/theme.test.ts diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index 06ba59d..d0f9ee1 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -593,7 +593,7 @@ 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 ctx: Record = { textAlign: 'center', font: '', @@ -606,6 +606,7 @@ describe('label placement', () => { y, align: this.textAlign as string, font: this.font as string, + fill: this.fillStyle as string, }) }, } @@ -623,7 +624,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 @@ -634,7 +640,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) @@ -656,6 +663,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 3874247..f815291 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -18,7 +18,14 @@ // Derived from chartr (https://github.com/rengwu/chartr), MIT, Copyright (c) 2026 John Goh. import { computeLayout, structureSignature, TAU } from './layout' -import { STAR, LABEL, SESSION_HUE, visualState, hexA, type VisualState } from './theme' +import { + STAR, + SESSION_HUE, + visualState, + priorityLabelColor, + hexA, + type VisualState, +} from './theme' import { GRAMMAR, type SessionState } from './session' import { analyzeFocus, type Focus } from './focus' import { edgeKey, isMiniWorkflowEdge, workflowEdges, type WorkflowEdge } from './workflow' @@ -29,6 +36,7 @@ import { type WorkflowVisualState, } from './workflow-visual' import type { Ticket } from './model' +import { ticketWorkPriority, type WorkPriority } from './work-priority' export type SelectHandler = (num: number | null) => void @@ -56,6 +64,7 @@ interface Node { type: string parentIssue: number | 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 @@ -89,17 +98,9 @@ function hits(a: Box, b: Box): boolean { } // 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 = { - frontier: 0, - claimed: 1, - resolved: 2, - blocked: 3, - out_of_scope: 4, -} - +// 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 @@ -355,12 +356,14 @@ export class StarMap { n.title = t.title n.type = t.type const vstate = visualState(t) + const priority = ticketWorkPriority(t) const sstate = sessions[t.num] ?? null - 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(' · ')) @@ -388,6 +391,7 @@ export class StarMap { type: t.type, parentIssue: t.parentIssue, vstate: visualState(t), + priority: ticketWorkPriority(t), sstate: sessions[t.num] ?? null, x: p.x, y: p.y, @@ -1336,11 +1340,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) @@ -1406,7 +1414,7 @@ export class StarMap { text, x: v.sx, y: c.y, - 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 900614d..bd0d2c5 100644 --- a/web/src/lib/starmap/theme.ts +++ b/web/src/lib/starmap/theme.ts @@ -14,6 +14,7 @@ // Derived from chartr (https://github.com/rengwu/chartr), MIT, Copyright (c) 2026 John Goh. import type { Ticket } from './model' +import type { WorkPriority } from './work-priority' export type VisualState = | 'resolved' @@ -54,6 +55,19 @@ export const LABEL: Record = { 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 From 3c59d8e8eb63b15c8cff4f587d469eccb3807f3c Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:44:19 -0500 Subject: [PATCH 14/18] feat(web): render dimensional issue nodes --- web/src/lib/StarMap.svelte | 9 + web/src/lib/StarMap.test.ts | 32 ++ web/src/lib/starmap/core-visual.test.ts | 374 ++++++++++-------------- web/src/lib/starmap/starmap.ts | 148 +++++----- 4 files changed, 262 insertions(+), 301 deletions(-) diff --git a/web/src/lib/StarMap.svelte b/web/src/lib/StarMap.svelte index 474dfa1..b5f419b 100644 --- a/web/src/lib/StarMap.svelte +++ b/web/src/lib/StarMap.svelte @@ -43,6 +43,14 @@ onMount(() => { renderer = new Renderer() + const motionQuery = typeof matchMedia === 'function' + ? matchMedia('(prefers-reduced-motion: reduce)') + : null + const updateReducedMotion = (event: MediaQueryListEvent) => { + renderer?.setReducedMotion(event.matches) + } + renderer.setReducedMotion(motionQuery?.matches ?? false) + motionQuery?.addEventListener('change', updateReducedMotion) const background = getComputedStyle(host).getPropertyValue('--map-background').trim() renderer.setBackground(background) renderer.mount(host) @@ -51,6 +59,7 @@ }) return () => { + motionQuery?.removeEventListener('change', updateReducedMotion) renderer?.destroy() renderer = undefined } diff --git a/web/src/lib/StarMap.test.ts b/web/src/lib/StarMap.test.ts index 8b17a27..b914656 100644 --- a/web/src/lib/StarMap.test.ts +++ b/web/src/lib/StarMap.test.ts @@ -19,6 +19,7 @@ afterEach(async () => { document.head.querySelectorAll('[data-test-app-css]').forEach((style) => style.remove()) document.documentElement.style.removeProperty('--map-background') vi.restoreAllMocks() + vi.unstubAllGlobals() }) function space(number: number): SpaceModel { @@ -80,6 +81,37 @@ describe('StarMap wrapper', () => { expect(setBackground).toHaveBeenCalledWith('rgb(12, 34, 56)') }) + it('forwards reduced-motion changes to one renderer and removes its listener', async () => { + let change: ((event: MediaQueryListEvent) => void) | undefined + const removeEventListener = vi.fn() + vi.stubGlobal('matchMedia', vi.fn(() => ({ + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => { + change = listener + }, + removeEventListener, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }))) + const setReducedMotion = vi.spyOn(Renderer.prototype, 'setReducedMotion') + const target = document.createElement('div') + document.body.appendChild(target) + + const component = mount(StarMap, { target, props: { space: space(42) } }) + mounted.push(component) + flushSync() + change?.({ matches: false } as MediaQueryListEvent) + + expect(setReducedMotion.mock.calls).toEqual([[true], [false]]) + expect(new Set(setReducedMotion.mock.instances).size).toBe(1) + await unmount(component) + mounted.splice(mounted.indexOf(component), 1) + expect(removeEventListener).toHaveBeenCalledWith('change', change) + }) + it('feeds reactive space prop updates to the renderer', () => { const setModel = vi.spyOn(Renderer.prototype, 'setModel') const target = document.createElement('div') diff --git a/web/src/lib/starmap/core-visual.test.ts b/web/src/lib/starmap/core-visual.test.ts index cee1556..85478cd 100644 --- a/web/src/lib/starmap/core-visual.test.ts +++ b/web/src/lib/starmap/core-visual.test.ts @@ -2,121 +2,85 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { StarMap } from './starmap' import type { Ticket } from './model' -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 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, + 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[] -} { - const fills: Fill[] = [] - const strokes: Stroke[] = [] +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 } - }, - 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 } + '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', () => { @@ -142,7 +106,11 @@ describe('issue core visual grammar', () => { document.body.replaceChildren() }) - function paint(ticket: Ticket, currentIssue: number | null = null): { fills: Fill[]; strokes: Stroke[] } { + function paint( + ticket: Ticket, + currentIssue: number | null = null, + selectedIssue: number | null = null, + ) { const recording = recordingContext() getContext.mockReturnValue(recording.ctx as never) const host = document.createElement('div') @@ -152,6 +120,7 @@ describe('issue core visual grammar', () => { const map = new StarMap() map.mount(host) map.setModel([ticket], {}, currentIssue) + if (selectedIssue !== null) map.select(selectedIssue) const frame = frames.pop() frames = [] frame?.(0) @@ -159,145 +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('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('paints every incomplete state with its unchanged glow, black disk, and status rim', () => { - for (const expected of [ - { - ticket: FRONTIER, - glow: [ - { at: 0, color: 'rgba(47,155,224,0.765)' }, - { at: 0.4, color: 'rgba(47,155,224,0.198)' }, - { at: 1, color: 'rgba(47,155,224,0)' }, - ], - blackRadius: 10.125, - rim: 'rgba(138,216,255,0.95)', - width: 3.24, - }, - { - ticket: CLAIMED, - glow: [ - { at: 0, color: 'rgba(255,176,32,0.85)' }, - { at: 0.4, color: 'rgba(255,176,32,0.22)' }, - { at: 1, color: 'rgba(255,176,32,0)' }, - ], - blackRadius: 9, - rim: 'rgba(255,216,115,0.95)', - width: 2.88, - }, - { - ticket: BLOCKED, - glow: [ - { at: 0, color: 'rgba(154,111,111,0.85)' }, - { at: 0.4, color: 'rgba(154,111,111,0.22)' }, - { at: 1, color: 'rgba(154,111,111,0)' }, - ], - blackRadius: 5.625, - rim: 'rgba(226,195,195,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('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 both CURRENT rings around an incomplete issue after its hollow core', () => { - const { fills, strokes } = paint(BLOCKED, BLOCKED.num) + it('keeps CURRENT as label semantics without drawing CURRENT rings', () => { + const { paints, texts } = paint(BLOCKED, BLOCKED.num) - expect(fills).toHaveLength(2) - expect(fills[1]).toEqual({ style: '#000', arc: { radius: 5.625 } }) - expect(strokes.map((stroke) => ({ style: stroke.style, lineWidth: stroke.lineWidth, radius: stroke.arc?.radius }))).toEqual([ - { style: 'rgba(226,195,195,0.95)', lineWidth: 2.2, radius: 4.525 }, - { style: 'rgba(255,255,255,0.95)', lineWidth: 2, radius: 13.625 }, - { style: 'rgba(255,255,255,0.55)', lineWidth: 1, radius: 18.625 }, - ]) + expect(outerRings(paints)).toHaveLength(0) + expect(texts.some((text) => text.startsWith('CURRENT'))).toBe(true) }) - 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')! + 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) - expect(relationshipRims).toHaveLength(1) - expect(relationshipRims[0].arc!.radius).toBeGreaterThan(blackCore.arc!.radius) - expect(incomplete.strokes).toContainEqual({ - style: 'rgba(226,195,195,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) - }) + const rings = outerRings(recording.paints) + const bodyRadius = bodyFill(recording.paints).arc!.radius + map.destroy() - 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(138,216,255,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) + expect(rings).toHaveLength(2) + expect(Math.min(...rings.map((ring) => ring.arc!.radius))).toBeGreaterThanOrEqual(bodyRadius + 7) + }) - 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) + 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]) } + + const first = takeGlow() + const second = takeGlow() + map.destroy() + expect(second).toEqual(first) }) }) diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index f815291..c4015b7 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -19,10 +19,10 @@ import { computeLayout, structureSignature, TAU } from './layout' import { - STAR, SESSION_HUE, visualState, priorityLabelColor, + priorityStarStyle, hexA, type VisualState, } from './theme' @@ -50,7 +50,6 @@ const CONTEXT_ALPHA = 0.3 const CONTEXT_EDGE_ALPHA = 0.45 const SELECTED_EDGE_WIDTH_SCALE = 1.7 const SELECTED_EDGE_ARROW_SCALE = 1.25 -const SUBISSUE_RIM = 'rgba(170,145,255,0.82)' interface RenderEdge extends WorkflowEdge { state: WorkflowVisualState @@ -295,6 +294,7 @@ export class StarMap { // operator last saw it rather than defaulting it below. #labelSide = new Map() #bg = DEFAULT_BG + #reducedMotion = false #onSelect: SelectHandler = () => {} #ro: ResizeObserver | null = null #detach: (() => void)[] = [] @@ -423,6 +423,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 { @@ -552,7 +556,7 @@ export class StarMap { } #radius(n: Node): number { - return STAR[n.vstate].r * ISSUE_RADIUS_SCALE + 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 @@ -1042,13 +1046,12 @@ export class StarMap { } #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 isF = n.vstate === 'frontier', - isC = n.vstate === 'claimed' - const beat = 0.5 + 0.5 * Math.sin(t * 2.8) + 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) @@ -1062,85 +1065,79 @@ export class StarMap { g.fill() const cr = this.#radius(n) - 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 (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 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() - } - // A live claim breathes with two soft rings. A session overlay speaks for - // the claim when there is one, so the vanilla claimed rings stand down rather - // than competing with the moon's orbit. - if (isC && !n.sstate) { + 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 + 5 + 1.2 * beat, 0, TAU) + g.arc(x, y, cr + 7 + beat, 0, TAU) g.stroke() - g.strokeStyle = hexA(c.core, 0.18 + 0.14 * beat) - 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 + 11 + 1.8 * beat, 0, TAU) + g.arc(x, y, cr + 7, 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 - g.beginPath() - g.arc(x, y, cr + 8, 0, TAU) - g.stroke() - g.strokeStyle = 'rgba(255,255,255,0.55)' - g.lineWidth = 1 - g.beginPath() - g.arc(x, y, cr + 13, 0, TAU) - g.stroke() - } 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() } } @@ -1326,8 +1323,9 @@ export class StarMap { const core = this.#radius(n) let r = core + 2 if (n.sstate) r = core + 15 - 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 + 9) + if (this.#selected === n.num) r = Math.max(r, core + (hasStatusRing ? 15 : 9)) vis.push({ n, sx, sy, rad: r * s }) } From 4f48937c07d5f5f0d15eaa165aa16b6f03445513 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:45:54 -0500 Subject: [PATCH 15/18] feat(web): compact workflow arrowheads --- web/src/lib/starmap/edge-visual.test.ts | 21 +++++++++++++-------- web/src/lib/starmap/starmap.ts | 6 ++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/web/src/lib/starmap/edge-visual.test.ts b/web/src/lib/starmap/edge-visual.test.ts index 1133270..29b0bc1 100644 --- a/web/src/lib/starmap/edge-visual.test.ts +++ b/web/src/lib/starmap/edge-visual.test.ts @@ -219,10 +219,7 @@ describe('dependency-edge visual treatment', () => { 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) @@ -267,11 +264,17 @@ describe('dependency-edge visual treatment', () => { 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) + 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) @@ -364,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] @@ -410,7 +414,7 @@ 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') @@ -418,6 +422,7 @@ describe('dependency-edge visual treatment', () => { 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: 3, dash: [], cap: 'round', alpha: 1 }) expect(stroke.curves).toHaveLength(1) diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index c4015b7..3260274 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -49,7 +49,6 @@ const ISSUE_RADIUS_SCALE = 1.25 const CONTEXT_ALPHA = 0.3 const CONTEXT_EDGE_ALPHA = 0.45 const SELECTED_EDGE_WIDTH_SCALE = 1.7 -const SELECTED_EDGE_ARROW_SCALE = 1.25 interface RenderEdge extends WorkflowEdge { state: WorkflowVisualState @@ -1029,9 +1028,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, From 9def3c3c60b6e97a3b72625a14493c60a8ba8c71 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:57:21 -0500 Subject: [PATCH 16/18] docs: record node visual hierarchy --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4a5d1..7a48a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- 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. - Restored dependency and parent relationship lines from cached Markdown issue From 1b78e4b9376823f01cc3ae0dfc14f04ac3d5b168 Mon Sep 17 00:00:00 2001 From: Phillip French <57448196+teloverge@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:12:06 -0500 Subject: [PATCH 17/18] feat: make headless server the default and gate desktop mode - Isolate Tauri and OS credential support behind the desktop feature - Add authenticated Tailnet serving and cross-platform URL helpers --- .github/workflows/ci.yml | 5 +- .github/workflows/linux-bundle.yml | 1 + .github/workflows/release.yml | 8 +- .nvmrc | 1 + AGENTS.md | 15 +- CHANGELOG.md | 18 +++ CLAUDE.md | 9 +- README.md | 140 ++++++++++++------- crates/app/Cargo.toml | 31 ++-- crates/app/build.rs | 6 + crates/app/src/cli.rs | 10 +- crates/app/src/desktop_main.rs | 3 - crates/app/src/entrypoints.rs | 66 +++++++-- crates/app/src/lib.rs | 4 +- crates/app/src/runtime.rs | 4 +- crates/app/tests/application_process_test.rs | 2 + crates/app/tests/auth_activation_test.rs | 2 + crates/app/tests/desktop_runtime_test.rs | 2 + crates/app/tests/desktop_window_test.rs | 2 + crates/app/tests/serve_test.rs | 37 ++++- crates/github/Cargo.toml | 6 +- crates/github/src/auth.rs | 40 +++++- crates/github/src/credentials.rs | 5 + crates/github/tests/credential_store_test.rs | 2 + scripts/build-linux-packages.sh | 2 +- scripts/build-macos-universal.sh | 2 +- scripts/build-windows-nsis.ps1 | 2 +- scripts/get-stellr-tailnet-url.cmd | 11 ++ scripts/get-stellr-tailnet-url.ps1 | 31 ++++ scripts/get-stellr-tailnet-url.sh | 30 ++++ scripts/install-linux-dependencies.sh | 39 ++++++ scripts/serve-tailnet.sh | 103 ++++++++++++++ web/.npmrc | 1 + web/package-lock.json | 16 ++- web/package.json | 13 +- web/src/App.svelte | 8 +- web/src/lib/Sidebar.svelte | 7 +- web/src/lib/Sidebar.test.ts | 9 +- web/src/lib/control.svelte.ts | 40 +++++- web/src/lib/control.test.ts | 15 ++ 40 files changed, 622 insertions(+), 126 deletions(-) create mode 100644 .nvmrc create mode 100644 scripts/get-stellr-tailnet-url.cmd create mode 100644 scripts/get-stellr-tailnet-url.ps1 create mode 100755 scripts/get-stellr-tailnet-url.sh create mode 100755 scripts/install-linux-dependencies.sh create mode 100755 scripts/serve-tailnet.sh create mode 100644 web/.npmrc 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..5dcaa3d 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 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/README.md b/README.md index 482c74c..f62e4ac 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 -- serve +cargo run -p stellr-app -- 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..cac34e1 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stellr-app" version = "0.1.0" -default-run = "stellr-desktop" +default-run = "stellr" edition.workspace = true license.workspace = true repository.workspace = true @@ -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 @@ -24,12 +38,12 @@ serde_json.workspace = true stellr-core = { path = "../core" } stellr-github = { path = "../github" } 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 +51,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..c6bb048 100644 --- a/crates/app/src/runtime.rs +++ b/crates/app/src/runtime.rs @@ -92,9 +92,10 @@ impl Provider for ProviderSlot { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionAuth { Required, + Fixed(String), Disabled, } @@ -217,6 +218,7 @@ 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); diff --git a/crates/app/tests/application_process_test.rs b/crates/app/tests/application_process_test.rs index aa8cd5d..31df953 100644 --- a/crates/app/tests/application_process_test.rs +++ b/crates/app/tests/application_process_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "desktop")] + use std::process::Stdio; use axum::{Json, Router, routing::post}; 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/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/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/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/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..75f2ae8 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -373,7 +373,13 @@ /> {/if}
- {#if modelLoading} + {#if control.status === 'unauthorized'} + + {:else if modelLoading} 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/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') From 3512528c7d58fc301285af153237527e0b010050 Mon Sep 17 00:00:00 2001 From: Phillip French <57448196+teloverge@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:17:35 -0500 Subject: [PATCH 18/18] fix(windows): select desktop binary for Tauri packaging --- README.md | 4 ++-- crates/app/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f62e4ac..1909d0f 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ Node lockfile, so running plain `npm audit fix` there will fail. Authenticate with `gh auth login` or set `GITHUB_TOKEN`, then start the server: ```bash -cargo run -p stellr-app -- serve -cargo run -p stellr-app -- serve --addr 127.0.0.1:0 --issue 70 +cargo run -p stellr-app --bin stellr -- serve +cargo run -p stellr-app --bin stellr -- serve --addr 127.0.0.1:0 --issue 70 ``` Open the printed `stellr cockpit` URL in a browser. The executable serves the diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index cac34e1..de5b139 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stellr-app" version = "0.1.0" -default-run = "stellr" +default-run = "stellr-desktop" edition.workspace = true license.workspace = true repository.workspace = true